feat: add equality operators, make history screen paginated, random performance updates
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:kooltab2/views/settings_view.dart';
|
|||||||
import '../viewmodels/pin_lock_view_model.dart';
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
import '../views/bar_screen_view.dart';
|
import '../views/bar_screen_view.dart';
|
||||||
import '../views/dev_menu_view.dart';
|
import '../views/dev_menu_view.dart';
|
||||||
|
import '../views/error_screen_view.dart';
|
||||||
import '../views/history_screen_view.dart';
|
import '../views/history_screen_view.dart';
|
||||||
import '../views/pin_lock_view.dart';
|
import '../views/pin_lock_view.dart';
|
||||||
import '../views/product_form_view.dart';
|
import '../views/product_form_view.dart';
|
||||||
@@ -80,6 +81,10 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
|
|||||||
path: '/dev',
|
path: '/dev',
|
||||||
builder: (context, state) => const DevMenuView(),
|
builder: (context, state) => const DevMenuView(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/error',
|
||||||
|
builder: (context, state) => const ErrorScreenView(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 4;
|
int get schemaVersion => 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
|
|||||||
@@ -30,4 +30,35 @@ class BarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get isOpen => status == 'open';
|
bool get isOpen => status == 'open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BarTab &&
|
||||||
|
id == other.id &&
|
||||||
|
customerName == other.customerName &&
|
||||||
|
status == other.status &&
|
||||||
|
openedAt == other.openedAt &&
|
||||||
|
closedAt == other.closedAt &&
|
||||||
|
_listEquals(items, other.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
customerName,
|
||||||
|
status,
|
||||||
|
openedAt,
|
||||||
|
closedAt,
|
||||||
|
Object.hashAll(items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEquals<T>(List<T>? a, List<T>? b) {
|
||||||
|
if (identical(a, b)) return true;
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,33 @@ class ClosedTab {
|
|||||||
|
|
||||||
String get formattedTotal =>
|
String get formattedTotal =>
|
||||||
NumberFormat.simpleCurrency().format(totalInCents / 100);
|
NumberFormat.simpleCurrency().format(totalInCents / 100);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is ClosedTab &&
|
||||||
|
id == other.id &&
|
||||||
|
originalTabId == other.originalTabId &&
|
||||||
|
customerName == other.customerName &&
|
||||||
|
closedAt == other.closedAt &&
|
||||||
|
_listEquals(items, other.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
originalTabId,
|
||||||
|
customerName,
|
||||||
|
closedAt,
|
||||||
|
Object.hashAll(items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEquals<T>(List<T>? a, List<T>? b) {
|
||||||
|
if (identical(a, b)) return true;
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,25 @@ class ClosedTabItem {
|
|||||||
});
|
});
|
||||||
|
|
||||||
int get lineTotalInCents => quantity * unitPriceInCents;
|
int get lineTotalInCents => quantity * unitPriceInCents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is ClosedTabItem &&
|
||||||
|
id == other.id &&
|
||||||
|
closedTabId == other.closedTabId &&
|
||||||
|
productId == other.productId &&
|
||||||
|
productName == other.productName &&
|
||||||
|
quantity == other.quantity &&
|
||||||
|
unitPriceInCents == other.unitPriceInCents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
closedTabId,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
quantity,
|
||||||
|
unitPriceInCents,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,29 @@ class Product {
|
|||||||
active: active ?? this.active,
|
active: active ?? this.active,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is Product &&
|
||||||
|
id == other.id &&
|
||||||
|
name == other.name &&
|
||||||
|
category == other.category &&
|
||||||
|
stockQuantity == other.stockQuantity &&
|
||||||
|
lowStockThreshold == other.lowStockThreshold &&
|
||||||
|
priceInCents == other.priceInCents &&
|
||||||
|
imagePath == other.imagePath &&
|
||||||
|
active == other.active;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
stockQuantity,
|
||||||
|
lowStockThreshold,
|
||||||
|
priceInCents,
|
||||||
|
imagePath,
|
||||||
|
active,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ class AppSettings {
|
|||||||
pinRequired: false,
|
pinRequired: false,
|
||||||
themeMode: AppThemeMode.system,
|
themeMode: AppThemeMode.system,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is AppSettings &&
|
||||||
|
pinRequired == other.pinRequired &&
|
||||||
|
themeMode == other.themeMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(pinRequired, themeMode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,25 @@ class TabItem {
|
|||||||
String get formattedUnitPrice {
|
String get formattedUnitPrice {
|
||||||
return '€${(unitPriceInCents / 100).toStringAsFixed(2)}';
|
return '€${(unitPriceInCents / 100).toStringAsFixed(2)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is TabItem &&
|
||||||
|
id == other.id &&
|
||||||
|
tabId == other.tabId &&
|
||||||
|
productId == other.productId &&
|
||||||
|
productName == other.productName &&
|
||||||
|
quantity == other.quantity &&
|
||||||
|
unitPriceInCents == other.unitPriceInCents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
tabId,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
quantity,
|
||||||
|
unitPriceInCents,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ abstract class BarTabService {
|
|||||||
Future<void> addProductToTab({
|
Future<void> addProductToTab({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required Product product,
|
required Product product,
|
||||||
|
Future<void> Function()? stockAdjustment,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> updateTabItemQuantity({
|
Future<void> updateTabItemQuantity({
|
||||||
required String tabItemId,
|
required String tabItemId,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
|
Future<void> Function()? stockAdjustment,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Archives the tab's current items into history and clears them.
|
/// Archives the tab's current items into history and clears them.
|
||||||
@@ -30,6 +32,16 @@ abstract class BarTabService {
|
|||||||
Future<void> closeTab(String tabId);
|
Future<void> closeTab(String tabId);
|
||||||
|
|
||||||
Future<List<ClosedTab>> getClosedTabs();
|
Future<List<ClosedTab>> getClosedTabs();
|
||||||
|
|
||||||
|
Future<List<ClosedTab>> getClosedTabsPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
String? customerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<int> getClosedTabCount({String? customerName});
|
||||||
|
|
||||||
|
Future<List<String>> getDistinctCustomerNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
class DriftBarTabService implements BarTabService {
|
class DriftBarTabService implements BarTabService {
|
||||||
@@ -144,6 +156,7 @@ class DriftBarTabService implements BarTabService {
|
|||||||
Future<void> addProductToTab({
|
Future<void> addProductToTab({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required Product product,
|
required Product product,
|
||||||
|
Future<void> Function()? stockAdjustment,
|
||||||
}) async {
|
}) async {
|
||||||
await database.transaction(() async {
|
await database.transaction(() async {
|
||||||
final existingItemQuery = database.select(database.tabItems)
|
final existingItemQuery = database.select(database.tabItems)
|
||||||
@@ -162,6 +175,10 @@ class DriftBarTabService implements BarTabService {
|
|||||||
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (stockAdjustment != null) {
|
||||||
|
await stockAdjustment();
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +195,10 @@ class DriftBarTabService implements BarTabService {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (stockAdjustment != null) {
|
||||||
|
await stockAdjustment();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +206,19 @@ class DriftBarTabService implements BarTabService {
|
|||||||
Future<void> updateTabItemQuantity({
|
Future<void> updateTabItemQuantity({
|
||||||
required String tabItemId,
|
required String tabItemId,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
|
Future<void> Function()? stockAdjustment,
|
||||||
}) async {
|
}) async {
|
||||||
|
await database.transaction(() async {
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
final deleteQuery = database.delete(database.tabItems)
|
final deleteQuery = database.delete(database.tabItems)
|
||||||
..where((item) => item.id.equals(tabItemId));
|
..where((item) => item.id.equals(tabItemId));
|
||||||
|
|
||||||
await deleteQuery.go();
|
await deleteQuery.go();
|
||||||
|
|
||||||
|
if (stockAdjustment != null) {
|
||||||
|
await stockAdjustment();
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +226,11 @@ class DriftBarTabService implements BarTabService {
|
|||||||
..where((item) => item.id.equals(tabItemId));
|
..where((item) => item.id.equals(tabItemId));
|
||||||
|
|
||||||
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
|
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
|
||||||
|
|
||||||
|
if (stockAdjustment != null) {
|
||||||
|
await stockAdjustment();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -255,8 +288,26 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ClosedTab>> getClosedTabs() async {
|
Future<List<ClosedTab>> getClosedTabs() async {
|
||||||
final query = database.select(database.closedTabs)
|
final count = await getClosedTabCount();
|
||||||
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]);
|
if (count == 0) return [];
|
||||||
|
|
||||||
|
return getClosedTabsPaginated(limit: count, offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ClosedTab>> getClosedTabsPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
String? customerName,
|
||||||
|
}) async {
|
||||||
|
var query = database.select(database.closedTabs)
|
||||||
|
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)])
|
||||||
|
..limit(limit, offset: offset);
|
||||||
|
|
||||||
|
if (customerName != null && customerName.isNotEmpty) {
|
||||||
|
query = query
|
||||||
|
..where((tab) => tab.customerName.equals(customerName));
|
||||||
|
}
|
||||||
|
|
||||||
final closedTabRows = await query.get();
|
final closedTabRows = await query.get();
|
||||||
|
|
||||||
@@ -281,4 +332,30 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
return closedTabs;
|
return closedTabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getClosedTabCount({String? customerName}) async {
|
||||||
|
final countExpr = database.closedTabs.id.count();
|
||||||
|
|
||||||
|
var query = database.selectOnly(database.closedTabs)
|
||||||
|
..addColumns([countExpr]);
|
||||||
|
|
||||||
|
if (customerName != null && customerName.isNotEmpty) {
|
||||||
|
query = query
|
||||||
|
..where(database.closedTabs.customerName.equals(customerName));
|
||||||
|
}
|
||||||
|
|
||||||
|
final row = await query.getSingle();
|
||||||
|
return row.read(countExpr) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getDistinctCustomerNames() async {
|
||||||
|
final rows = await database.select(database.closedTabs).get();
|
||||||
|
return rows
|
||||||
|
.map((row) => row.customerName)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
const kUpdateServerUrl = 'https://updater.brammie15.dev';
|
||||||
@@ -36,6 +36,21 @@ class UpdateInfo {
|
|||||||
download: json["download"] ?? "",
|
download: json["download"] ?? "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is UpdateInfo &&
|
||||||
|
update == other.update &&
|
||||||
|
version == other.version &&
|
||||||
|
notes == other.notes &&
|
||||||
|
mandatory == other.mandatory &&
|
||||||
|
sha256 == other.sha256 &&
|
||||||
|
download == other.download;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(update, version, notes, mandatory, sha256, download);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppUpdateUtil {
|
class AppUpdateUtil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:kooltab2/utils/app_update_util.dart';
|
import 'package:kooltab2/utils/app_update_util.dart';
|
||||||
import 'package:ota_update/ota_update.dart';
|
import 'package:ota_update/ota_update.dart';
|
||||||
|
|
||||||
|
import 'app_config.dart';
|
||||||
|
|
||||||
class UpdateChecker {
|
class UpdateChecker {
|
||||||
static bool _hasChecked = false;
|
static bool _hasChecked = false;
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ class UpdateChecker {
|
|||||||
|
|
||||||
_hasChecked = true;
|
_hasChecked = true;
|
||||||
|
|
||||||
final updater = AppUpdateUtil(serverUrl: "https://updater.brammie15.dev");
|
final updater = AppUpdateUtil(serverUrl: kUpdateServerUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final update = await updater.checkForUpdate();
|
final update = await updater.checkForUpdate();
|
||||||
@@ -72,7 +74,7 @@ Future<void> _downloadAndInstall(UpdateInfo update) async {
|
|||||||
try {
|
try {
|
||||||
final url = update.download.startsWith("http")
|
final url = update.download.startsWith("http")
|
||||||
? update.download
|
? update.download
|
||||||
: "https://updater.brammie15.dev${update.download}";
|
: "$kUpdateServerUrl${update.download}";
|
||||||
OtaUpdate()
|
OtaUpdate()
|
||||||
.execute(
|
.execute(
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ class BarScreenViewModel extends ChangeNotifier {
|
|||||||
throw Exception('Product is out of stock.');
|
throw Exception('Product is out of stock.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await barTabService.addProductToTab(tabId: tab.id, product: product);
|
await barTabService.addProductToTab(
|
||||||
|
tabId: tab.id,
|
||||||
await inventory.decreaseStock(product.id, 1);
|
product: product,
|
||||||
|
stockAdjustment: () => inventory.decreaseStock(product.id, 1),
|
||||||
|
);
|
||||||
|
|
||||||
await _reloadTabs();
|
await _reloadTabs();
|
||||||
}
|
}
|
||||||
@@ -101,13 +103,14 @@ class BarScreenViewModel extends ChangeNotifier {
|
|||||||
await barTabService.updateTabItemQuantity(
|
await barTabService.updateTabItemQuantity(
|
||||||
tabItemId: item.id,
|
tabItemId: item.id,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
);
|
stockAdjustment: () async {
|
||||||
|
|
||||||
if (difference > 0) {
|
if (difference > 0) {
|
||||||
await inventory.decreaseStock(item.productId, difference);
|
await inventory.decreaseStock(item.productId, difference);
|
||||||
} else if (difference < 0) {
|
} else if (difference < 0) {
|
||||||
await inventory.increaseStock(item.productId, -difference);
|
await inventory.increaseStock(item.productId, -difference);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await _reloadTabs();
|
await _reloadTabs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,4 +254,73 @@ class DevMenuViewModel extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> generateMockOrders({int count = 100}) async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final products = await productService.getProducts();
|
||||||
|
if (products.isEmpty) {
|
||||||
|
_lastAction = 'No products available — seed demo data first';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uuid = const Uuid();
|
||||||
|
final customerNames = [
|
||||||
|
'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank',
|
||||||
|
'Grace', 'Hank', 'Ivy', 'Jack', 'Kate', 'Leo',
|
||||||
|
'Mia', 'Noah', 'Olivia', 'Pete', 'Quinn', 'Rose',
|
||||||
|
'Sam', 'Tina', 'Umar', 'Vera', 'Wes', 'Xena',
|
||||||
|
'Yves', 'Zara',
|
||||||
|
];
|
||||||
|
|
||||||
|
await database.transaction(() async {
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
final customer = customerNames[i % customerNames.length];
|
||||||
|
final closedTabId = uuid.v4();
|
||||||
|
final itemCount = 1 + (i % 5);
|
||||||
|
|
||||||
|
final closedAt = DateTime.now().subtract(
|
||||||
|
Duration(
|
||||||
|
days: i % 90,
|
||||||
|
hours: i % 24,
|
||||||
|
minutes: i % 60,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await database.into(database.closedTabs).insert(
|
||||||
|
ClosedTabsCompanion.insert(
|
||||||
|
id: closedTabId,
|
||||||
|
originalTabId: uuid.v4(),
|
||||||
|
customerName: customer,
|
||||||
|
closedAt: closedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var j = 0; j < itemCount; j++) {
|
||||||
|
final product = products[(i + j) % products.length];
|
||||||
|
final quantity = 1 + ((i * 7 + j * 13) % 6);
|
||||||
|
|
||||||
|
await database.into(database.closedTabItems).insert(
|
||||||
|
ClosedTabItemsCompanion.insert(
|
||||||
|
id: uuid.v4(),
|
||||||
|
closedTabId: closedTabId,
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: quantity,
|
||||||
|
unitPriceInCents: product.priceInCents,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_lastAction = 'Generated $count mock orders across '
|
||||||
|
'${customerNames.length} customers';
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ class HistoryViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
HistoryViewModel({required this.barTabService});
|
HistoryViewModel({required this.barTabService});
|
||||||
|
|
||||||
|
static const int _pageSize = 20;
|
||||||
|
|
||||||
List<ClosedTab> _closedTabs = [];
|
List<ClosedTab> _closedTabs = [];
|
||||||
|
List<String> _customerNames = [];
|
||||||
|
String? _selectedCustomer;
|
||||||
|
int _offset = 0;
|
||||||
|
int _totalCount = 0;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
|
||||||
@@ -26,10 +33,18 @@ class HistoryViewModel extends ChangeNotifier {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> get customerNames => _customerNames;
|
||||||
|
|
||||||
|
String? get selectedCustomer => _selectedCustomer;
|
||||||
|
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
bool get hasLoaded => _hasLoaded;
|
bool get hasLoaded => _hasLoaded;
|
||||||
|
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
|
||||||
|
bool get hasMore => _closedTabs.length < _totalCount;
|
||||||
|
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
Future<void> ensureLoaded() async {
|
Future<void> ensureLoaded() async {
|
||||||
@@ -46,7 +61,18 @@ class HistoryViewModel extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_closedTabs = await barTabService.getClosedTabs();
|
final results = await Future.wait([
|
||||||
|
_fetchPage(offset: 0),
|
||||||
|
barTabService.getClosedTabCount(
|
||||||
|
customerName: _selectedCustomer,
|
||||||
|
),
|
||||||
|
barTabService.getDistinctCustomerNames(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
_closedTabs = results[0] as List<ClosedTab>;
|
||||||
|
_totalCount = results[1] as int;
|
||||||
|
_customerNames = results[2] as List<String>;
|
||||||
|
_offset = _closedTabs.length;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorMessage = 'Could not load tab history.';
|
_errorMessage = 'Could not load tab history.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,8 +82,43 @@ class HistoryViewModel extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_isLoadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final more = await _fetchPage(offset: _offset);
|
||||||
|
_closedTabs = [..._closedTabs, ...more];
|
||||||
|
_offset = _closedTabs.length;
|
||||||
|
} catch (_) {
|
||||||
|
_errorMessage = 'Could not load more tabs.';
|
||||||
|
} finally {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ClosedTab>> _fetchPage({required int offset}) {
|
||||||
|
return barTabService.getClosedTabsPaginated(
|
||||||
|
limit: _pageSize,
|
||||||
|
offset: offset,
|
||||||
|
customerName: _selectedCustomer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void search(String query) {
|
void search(String query) {
|
||||||
_searchQuery = query;
|
_searchQuery = query;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> filterByCustomer(String? customerName) async {
|
||||||
|
_selectedCustomer = customerName;
|
||||||
|
_searchQuery = '';
|
||||||
|
_offset = 0;
|
||||||
|
_closedTabs = [];
|
||||||
|
|
||||||
|
await load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:ota_update/ota_update.dart';
|
|||||||
|
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../utils/app_config.dart';
|
||||||
|
|
||||||
class SettingsViewModel extends ChangeNotifier {
|
class SettingsViewModel extends ChangeNotifier {
|
||||||
final SettingsService settingsService;
|
final SettingsService settingsService;
|
||||||
@@ -15,9 +16,7 @@ class SettingsViewModel extends ChangeNotifier {
|
|||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
final AppUpdateUtil _updater = AppUpdateUtil(
|
final AppUpdateUtil _updater = AppUpdateUtil(serverUrl: kUpdateServerUrl);
|
||||||
serverUrl: "https://updater.brammie15.dev",
|
|
||||||
);
|
|
||||||
|
|
||||||
bool _checkingForUpdates = false;
|
bool _checkingForUpdates = false;
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ class SettingsViewModel extends ChangeNotifier {
|
|||||||
}) async {
|
}) async {
|
||||||
final url = update.download.startsWith("http")
|
final url = update.download.startsWith("http")
|
||||||
? update.download
|
? update.download
|
||||||
: "https://updater.brammie15.dev${update.download}";
|
: "$kUpdateServerUrl${update.download}";
|
||||||
|
|
||||||
debugPrint("Download Url: ${url}");
|
debugPrint("Download Url: ${url}");
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
subtitle: 'Show FPS, memory, widget count',
|
subtitle: 'Show FPS, memory, widget count',
|
||||||
onTap: () => _showDebugOverlayInfo(),
|
onTap: () => _showDebugOverlayInfo(),
|
||||||
),
|
),
|
||||||
|
_Tile(
|
||||||
|
icon: Icons.error_outline_rounded,
|
||||||
|
title: 'Error screen',
|
||||||
|
subtitle: 'View the error screen UI',
|
||||||
|
onTap: () => context.push('/error'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_Section(
|
_Section(
|
||||||
@@ -141,6 +147,14 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
subtitle: 'Set all products below threshold',
|
subtitle: 'Set all products below threshold',
|
||||||
onTap: vm.isLoading ? null : () => vm.simulateLowStock(),
|
onTap: vm.isLoading ? null : () => vm.simulateLowStock(),
|
||||||
),
|
),
|
||||||
|
_Tile(
|
||||||
|
icon: Icons.history_rounded,
|
||||||
|
title: 'Generate 100 mock orders',
|
||||||
|
subtitle: 'Random customers, items, and amounts',
|
||||||
|
onTap: vm.isLoading
|
||||||
|
? null
|
||||||
|
: () => vm.generateMockOrders(count: 100),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_Section(
|
_Section(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class ErrorScreenView extends StatelessWidget {
|
||||||
|
const ErrorScreenView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
title: const Text('Error Screen'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: scheme.error.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: scheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Something went wrong',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: scheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'An unexpected error occurred.\nPlease try restarting the app.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: scheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/bar'),
|
||||||
|
icon: const Icon(Icons.home_rounded),
|
||||||
|
label: const Text('Go to bar screen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:kooltab2/views/widgets/closed_tab_card.dart';
|
import 'package:kooltab2/views/widgets/closed_tab_card.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../app/router.dart';
|
import '../app/router.dart';
|
||||||
import '../models/closed_tab.dart';
|
|
||||||
import '../models/closed_tab_item.dart';
|
|
||||||
import '../viewmodels/history_view_model.dart';
|
import '../viewmodels/history_view_model.dart';
|
||||||
|
|
||||||
class HistoryScreenView extends StatefulWidget {
|
class HistoryScreenView extends StatefulWidget {
|
||||||
@@ -17,10 +14,14 @@ class HistoryScreenView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<HistoryViewModel>().load();
|
context.read<HistoryViewModel>().load();
|
||||||
});
|
});
|
||||||
@@ -34,16 +35,23 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
// Called when you come back to this screen
|
|
||||||
context.read<HistoryViewModel>().load();
|
context.read<HistoryViewModel>().load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
routeObserver.unsubscribe(this);
|
routeObserver.unsubscribe(this);
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.extentAfter < 300) {
|
||||||
|
context.read<HistoryViewModel>().loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final viewModel = context.watch<HistoryViewModel>();
|
final viewModel = context.watch<HistoryViewModel>();
|
||||||
@@ -53,9 +61,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
|||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () => context.go('/bar'),
|
||||||
context.go('/bar');
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
@@ -108,6 +114,9 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
onChanged: viewModel.search,
|
onChanged: viewModel.search,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -117,14 +126,39 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_CustomerDropdown(
|
||||||
|
customerNames: viewModel.customerNames,
|
||||||
|
selectedCustomer: viewModel.selectedCustomer,
|
||||||
|
onSelected: viewModel.filterByCustomer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: viewModel.closedTabs.isEmpty
|
child: viewModel.closedTabs.isEmpty
|
||||||
? _EmptyState()
|
? _EmptyState()
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
itemCount: viewModel.closedTabs.length,
|
itemCount: viewModel.closedTabs.length +
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
(viewModel.hasMore || viewModel.isLoadingMore
|
||||||
|
? 1
|
||||||
|
: 0),
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: 10),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == viewModel.closedTabs.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final closedTab = viewModel.closedTabs[index];
|
final closedTab = viewModel.closedTabs[index];
|
||||||
|
|
||||||
return ClosedTabCard(closedTab: closedTab);
|
return ClosedTabCard(closedTab: closedTab);
|
||||||
@@ -139,6 +173,126 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CustomerDropdown extends StatelessWidget {
|
||||||
|
static const _allSentinel = r'$__all__$';
|
||||||
|
|
||||||
|
final List<String> customerNames;
|
||||||
|
final String? selectedCustomer;
|
||||||
|
final ValueChanged<String?> onSelected;
|
||||||
|
|
||||||
|
const _CustomerDropdown({
|
||||||
|
required this.customerNames,
|
||||||
|
required this.selectedCustomer,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isFiltered = selectedCustomer != null;
|
||||||
|
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
onSelected: (value) {
|
||||||
|
onSelected(value == _allSentinel ? null : value);
|
||||||
|
},
|
||||||
|
offset: const Offset(0, 44),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
color: Theme.of(context).cardTheme.color ?? scheme.surface,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: _allSentinel,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isFiltered
|
||||||
|
? Icons.people_outline
|
||||||
|
: Icons.people_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: isFiltered
|
||||||
|
? null
|
||||||
|
: scheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'All customers',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isFiltered ? FontWeight.w400 : FontWeight.w700,
|
||||||
|
color: isFiltered ? null : scheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (customerNames.isNotEmpty)
|
||||||
|
const PopupMenuDivider(height: 1),
|
||||||
|
...customerNames.map(
|
||||||
|
(name) => PopupMenuItem<String>(
|
||||||
|
value: name,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
name == selectedCustomer
|
||||||
|
? Icons.person_rounded
|
||||||
|
: Icons.person_outline_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(name, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)),
|
||||||
|
color: isFiltered
|
||||||
|
? scheme.primary.withValues(alpha: 0.08)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: isFiltered
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
selectedCustomer ?? 'Customer',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
color: isFiltered
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: isFiltered
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
|
|
||||||
Product? _existingProduct;
|
Product? _existingProduct;
|
||||||
String? _imagePath;
|
String? _imagePath;
|
||||||
|
bool _hasImage = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
_stockController.text = product.stockQuantity.toString();
|
_stockController.text = product.stockQuantity.toString();
|
||||||
_lowStockController.text = product.lowStockThreshold.toString();
|
_lowStockController.text = product.lowStockThreshold.toString();
|
||||||
_imagePath = product.imagePath;
|
_imagePath = product.imagePath;
|
||||||
|
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -109,8 +111,9 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
Future<void> _pickImage() async {
|
Future<void> _pickImage() async {
|
||||||
final pickedFile = await _imagePicker.pickImage(
|
final pickedFile = await _imagePicker.pickImage(
|
||||||
source: ImageSource.gallery,
|
source: ImageSource.gallery,
|
||||||
imageQuality: 85,
|
imageQuality: 80,
|
||||||
maxWidth: 1000,
|
maxWidth: 1000,
|
||||||
|
maxHeight: 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pickedFile == null) return;
|
if (pickedFile == null) return;
|
||||||
@@ -121,6 +124,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_imagePath = copiedImagePath;
|
_imagePath = copiedImagePath;
|
||||||
|
_hasImage = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +226,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImagePicker(BuildContext context) {
|
Widget _buildImagePicker(BuildContext context) {
|
||||||
final hasImage = _imagePath != null && File(_imagePath!).existsSync();
|
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: _pickImage,
|
onTap: _pickImage,
|
||||||
@@ -234,11 +237,22 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: hasImage
|
child: _hasImage
|
||||||
? Stack(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.file(File(_imagePath!), fit: BoxFit.fitHeight),
|
Image.file(
|
||||||
|
File(_imagePath!),
|
||||||
|
fit: BoxFit.fitHeight,
|
||||||
|
cacheWidth: 1000,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 12,
|
right: 12,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
|
|||||||
@@ -73,10 +73,17 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Image.file(
|
child: product.imagePath != null &&
|
||||||
|
product.imagePath!.isNotEmpty
|
||||||
|
? Image.file(
|
||||||
File(product.imagePath!),
|
File(product.imagePath!),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
cacheWidth: 112,
|
||||||
|
errorBuilder:
|
||||||
|
(context, error, stackTrace) =>
|
||||||
|
const Icon(Icons.image_not_supported_outlined),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.image_not_supported_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(product.name),
|
title: Text(product.name),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ProductTile extends StatelessWidget {
|
|||||||
final Product product;
|
final Product product;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
static const _tileImageSize = 280.0;
|
||||||
|
|
||||||
const ProductTile({
|
const ProductTile({
|
||||||
required this.product,
|
required this.product,
|
||||||
@@ -14,16 +15,6 @@ class ProductTile extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get _hasImage {
|
|
||||||
final imagePath = product.imagePath;
|
|
||||||
|
|
||||||
if (imagePath == null || imagePath.isEmpty) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return File(imagePath).existsSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -32,7 +23,10 @@ class ProductTile extends StatelessWidget {
|
|||||||
final outOfStock = product.stockQuantity <= 0;
|
final outOfStock = product.stockQuantity <= 0;
|
||||||
final lowStock =
|
final lowStock =
|
||||||
product.stockQuantity > 0 &&
|
product.stockQuantity > 0 &&
|
||||||
product.stockQuantity <= product.lowStockThreshold; // adjust name
|
product.stockQuantity <= product.lowStockThreshold;
|
||||||
|
|
||||||
|
final hasImage = product.imagePath != null &&
|
||||||
|
product.imagePath!.isNotEmpty;
|
||||||
|
|
||||||
final borderColor = outOfStock
|
final borderColor = outOfStock
|
||||||
? scheme.error.withValues(alpha: 0.7)
|
? scheme.error.withValues(alpha: 0.7)
|
||||||
@@ -59,21 +53,18 @@ class ProductTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
|
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
|
||||||
child: _hasImage
|
child: hasImage
|
||||||
? Image.file(
|
? Image.file(
|
||||||
File(product.imagePath!),
|
File(product.imagePath!),
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
|
cacheWidth: _tileImageSize.toInt(),
|
||||||
|
errorBuilder:
|
||||||
|
(context, error, stackTrace) => _noImage(scheme),
|
||||||
)
|
)
|
||||||
: Center(
|
: _noImage(scheme),
|
||||||
child: Icon(
|
|
||||||
Icons.image_not_supported_outlined,
|
|
||||||
size: 34,
|
|
||||||
color: scheme.onSurface.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_hasImage)
|
if (hasImage)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -126,6 +117,16 @@ class ProductTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _noImage(ColorScheme scheme) {
|
||||||
|
return Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
size: 34,
|
||||||
|
color: scheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StockBadge extends StatelessWidget {
|
class _StockBadge extends StatelessWidget {
|
||||||
@@ -141,8 +142,7 @@ class _StockBadge extends StatelessWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final low =
|
final low = product.stockQuantity <= product.lowStockThreshold;
|
||||||
product.stockQuantity <= product.lowStockThreshold; // adjust name
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
|||||||
Reference in New Issue
Block a user