feat: alphabet list, change Layout etc
This commit is contained in:
+9
-1
@@ -103,6 +103,10 @@ GoRouter createAppRouter({
|
||||
required AdminPinViewModel adminPinViewModel,
|
||||
required RemoteSyncViewModel remoteSyncViewModel,
|
||||
}) {
|
||||
// go_router drops `extra` when it rebuilds a route from its location, e.g.
|
||||
// when the app returns from Android's installer, so keep the last update
|
||||
UpdateInfo? lastUpdate;
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/bar',
|
||||
observers: [routeObserver, SentryNavigatorObserver()],
|
||||
@@ -194,8 +198,12 @@ GoRouter createAppRouter({
|
||||
|
||||
GoRoute(
|
||||
path: '/update-progress',
|
||||
redirect: (context, state) {
|
||||
if (state.extra case final UpdateInfo update) lastUpdate = update;
|
||||
return lastUpdate == null ? '/settings' : null;
|
||||
},
|
||||
builder: (context, state) {
|
||||
final update = state.extra as UpdateInfo;
|
||||
final update = lastUpdate!;
|
||||
final simulate = state.uri.queryParameters['simulate'] == 'true';
|
||||
final simulateError =
|
||||
state.uri.queryParameters['simulateError'] == 'true';
|
||||
|
||||
@@ -30,6 +30,10 @@ class Products extends Table {
|
||||
|
||||
IntColumn get lowStockThreshold => integer()();
|
||||
|
||||
/// Products like tap water that are never counted; their stock is ignored.
|
||||
BoolColumn get infiniteStock =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get priceInCents => integer()();
|
||||
|
||||
TextColumn get imagePath => text().nullable()();
|
||||
@@ -200,7 +204,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 14;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -295,6 +299,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
await _backfillPurchaseRows();
|
||||
await _deleteOrphanedPurchaseRows();
|
||||
}
|
||||
|
||||
if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) {
|
||||
await migrator.addColumn(products, products.infiniteStock);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,21 @@ class $ProductsTable extends Products
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _infiniteStockMeta = const VerificationMeta(
|
||||
'infiniteStock',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> infiniteStock = GeneratedColumn<bool>(
|
||||
'infinite_stock',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("infinite_stock" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _priceInCentsMeta = const VerificationMeta(
|
||||
'priceInCents',
|
||||
);
|
||||
@@ -102,6 +117,7 @@ class $ProductsTable extends Products
|
||||
category,
|
||||
stockQuantity,
|
||||
lowStockThreshold,
|
||||
infiniteStock,
|
||||
priceInCents,
|
||||
imagePath,
|
||||
active,
|
||||
@@ -161,6 +177,15 @@ class $ProductsTable extends Products
|
||||
} else if (isInserting) {
|
||||
context.missing(_lowStockThresholdMeta);
|
||||
}
|
||||
if (data.containsKey('infinite_stock')) {
|
||||
context.handle(
|
||||
_infiniteStockMeta,
|
||||
infiniteStock.isAcceptableOrUnknown(
|
||||
data['infinite_stock']!,
|
||||
_infiniteStockMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('price_in_cents')) {
|
||||
context.handle(
|
||||
_priceInCentsMeta,
|
||||
@@ -213,6 +238,10 @@ class $ProductsTable extends Products
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}low_stock_threshold'],
|
||||
)!,
|
||||
infiniteStock: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}infinite_stock'],
|
||||
)!,
|
||||
priceInCents: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}price_in_cents'],
|
||||
@@ -240,6 +269,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
final String category;
|
||||
final int stockQuantity;
|
||||
final int lowStockThreshold;
|
||||
|
||||
/// Products like tap water that are never counted; their stock is ignored.
|
||||
final bool infiniteStock;
|
||||
final int priceInCents;
|
||||
final String? imagePath;
|
||||
final bool active;
|
||||
@@ -249,6 +281,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
required this.category,
|
||||
required this.stockQuantity,
|
||||
required this.lowStockThreshold,
|
||||
required this.infiniteStock,
|
||||
required this.priceInCents,
|
||||
this.imagePath,
|
||||
required this.active,
|
||||
@@ -261,6 +294,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
map['category'] = Variable<String>(category);
|
||||
map['stock_quantity'] = Variable<int>(stockQuantity);
|
||||
map['low_stock_threshold'] = Variable<int>(lowStockThreshold);
|
||||
map['infinite_stock'] = Variable<bool>(infiniteStock);
|
||||
map['price_in_cents'] = Variable<int>(priceInCents);
|
||||
if (!nullToAbsent || imagePath != null) {
|
||||
map['image_path'] = Variable<String>(imagePath);
|
||||
@@ -276,6 +310,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
category: Value(category),
|
||||
stockQuantity: Value(stockQuantity),
|
||||
lowStockThreshold: Value(lowStockThreshold),
|
||||
infiniteStock: Value(infiniteStock),
|
||||
priceInCents: Value(priceInCents),
|
||||
imagePath: imagePath == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
@@ -295,6 +330,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
category: serializer.fromJson<String>(json['category']),
|
||||
stockQuantity: serializer.fromJson<int>(json['stockQuantity']),
|
||||
lowStockThreshold: serializer.fromJson<int>(json['lowStockThreshold']),
|
||||
infiniteStock: serializer.fromJson<bool>(json['infiniteStock']),
|
||||
priceInCents: serializer.fromJson<int>(json['priceInCents']),
|
||||
imagePath: serializer.fromJson<String?>(json['imagePath']),
|
||||
active: serializer.fromJson<bool>(json['active']),
|
||||
@@ -309,6 +345,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
'category': serializer.toJson<String>(category),
|
||||
'stockQuantity': serializer.toJson<int>(stockQuantity),
|
||||
'lowStockThreshold': serializer.toJson<int>(lowStockThreshold),
|
||||
'infiniteStock': serializer.toJson<bool>(infiniteStock),
|
||||
'priceInCents': serializer.toJson<int>(priceInCents),
|
||||
'imagePath': serializer.toJson<String?>(imagePath),
|
||||
'active': serializer.toJson<bool>(active),
|
||||
@@ -321,6 +358,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
String? category,
|
||||
int? stockQuantity,
|
||||
int? lowStockThreshold,
|
||||
bool? infiniteStock,
|
||||
int? priceInCents,
|
||||
Value<String?> imagePath = const Value.absent(),
|
||||
bool? active,
|
||||
@@ -330,6 +368,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
category: category ?? this.category,
|
||||
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
|
||||
infiniteStock: infiniteStock ?? this.infiniteStock,
|
||||
priceInCents: priceInCents ?? this.priceInCents,
|
||||
imagePath: imagePath.present ? imagePath.value : this.imagePath,
|
||||
active: active ?? this.active,
|
||||
@@ -345,6 +384,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
lowStockThreshold: data.lowStockThreshold.present
|
||||
? data.lowStockThreshold.value
|
||||
: this.lowStockThreshold,
|
||||
infiniteStock: data.infiniteStock.present
|
||||
? data.infiniteStock.value
|
||||
: this.infiniteStock,
|
||||
priceInCents: data.priceInCents.present
|
||||
? data.priceInCents.value
|
||||
: this.priceInCents,
|
||||
@@ -361,6 +403,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
..write('category: $category, ')
|
||||
..write('stockQuantity: $stockQuantity, ')
|
||||
..write('lowStockThreshold: $lowStockThreshold, ')
|
||||
..write('infiniteStock: $infiniteStock, ')
|
||||
..write('priceInCents: $priceInCents, ')
|
||||
..write('imagePath: $imagePath, ')
|
||||
..write('active: $active')
|
||||
@@ -375,6 +418,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
category,
|
||||
stockQuantity,
|
||||
lowStockThreshold,
|
||||
infiniteStock,
|
||||
priceInCents,
|
||||
imagePath,
|
||||
active,
|
||||
@@ -388,6 +432,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
||||
other.category == this.category &&
|
||||
other.stockQuantity == this.stockQuantity &&
|
||||
other.lowStockThreshold == this.lowStockThreshold &&
|
||||
other.infiniteStock == this.infiniteStock &&
|
||||
other.priceInCents == this.priceInCents &&
|
||||
other.imagePath == this.imagePath &&
|
||||
other.active == this.active);
|
||||
@@ -399,6 +444,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
final Value<String> category;
|
||||
final Value<int> stockQuantity;
|
||||
final Value<int> lowStockThreshold;
|
||||
final Value<bool> infiniteStock;
|
||||
final Value<int> priceInCents;
|
||||
final Value<String?> imagePath;
|
||||
final Value<bool> active;
|
||||
@@ -409,6 +455,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
this.category = const Value.absent(),
|
||||
this.stockQuantity = const Value.absent(),
|
||||
this.lowStockThreshold = const Value.absent(),
|
||||
this.infiniteStock = const Value.absent(),
|
||||
this.priceInCents = const Value.absent(),
|
||||
this.imagePath = const Value.absent(),
|
||||
this.active = const Value.absent(),
|
||||
@@ -420,6 +467,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
this.infiniteStock = const Value.absent(),
|
||||
required int priceInCents,
|
||||
this.imagePath = const Value.absent(),
|
||||
this.active = const Value.absent(),
|
||||
@@ -436,6 +484,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
Expression<String>? category,
|
||||
Expression<int>? stockQuantity,
|
||||
Expression<int>? lowStockThreshold,
|
||||
Expression<bool>? infiniteStock,
|
||||
Expression<int>? priceInCents,
|
||||
Expression<String>? imagePath,
|
||||
Expression<bool>? active,
|
||||
@@ -447,6 +496,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
if (category != null) 'category': category,
|
||||
if (stockQuantity != null) 'stock_quantity': stockQuantity,
|
||||
if (lowStockThreshold != null) 'low_stock_threshold': lowStockThreshold,
|
||||
if (infiniteStock != null) 'infinite_stock': infiniteStock,
|
||||
if (priceInCents != null) 'price_in_cents': priceInCents,
|
||||
if (imagePath != null) 'image_path': imagePath,
|
||||
if (active != null) 'active': active,
|
||||
@@ -460,6 +510,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
Value<String>? category,
|
||||
Value<int>? stockQuantity,
|
||||
Value<int>? lowStockThreshold,
|
||||
Value<bool>? infiniteStock,
|
||||
Value<int>? priceInCents,
|
||||
Value<String?>? imagePath,
|
||||
Value<bool>? active,
|
||||
@@ -471,6 +522,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
category: category ?? this.category,
|
||||
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
|
||||
infiniteStock: infiniteStock ?? this.infiniteStock,
|
||||
priceInCents: priceInCents ?? this.priceInCents,
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
active: active ?? this.active,
|
||||
@@ -496,6 +548,9 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
if (lowStockThreshold.present) {
|
||||
map['low_stock_threshold'] = Variable<int>(lowStockThreshold.value);
|
||||
}
|
||||
if (infiniteStock.present) {
|
||||
map['infinite_stock'] = Variable<bool>(infiniteStock.value);
|
||||
}
|
||||
if (priceInCents.present) {
|
||||
map['price_in_cents'] = Variable<int>(priceInCents.value);
|
||||
}
|
||||
@@ -519,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
||||
..write('category: $category, ')
|
||||
..write('stockQuantity: $stockQuantity, ')
|
||||
..write('lowStockThreshold: $lowStockThreshold, ')
|
||||
..write('infiniteStock: $infiniteStock, ')
|
||||
..write('priceInCents: $priceInCents, ')
|
||||
..write('imagePath: $imagePath, ')
|
||||
..write('active: $active, ')
|
||||
@@ -3800,6 +3856,7 @@ typedef $$ProductsTableCreateCompanionBuilder =
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
Value<bool> infiniteStock,
|
||||
required int priceInCents,
|
||||
Value<String?> imagePath,
|
||||
Value<bool> active,
|
||||
@@ -3812,6 +3869,7 @@ typedef $$ProductsTableUpdateCompanionBuilder =
|
||||
Value<String> category,
|
||||
Value<int> stockQuantity,
|
||||
Value<int> lowStockThreshold,
|
||||
Value<bool> infiniteStock,
|
||||
Value<int> priceInCents,
|
||||
Value<String?> imagePath,
|
||||
Value<bool> active,
|
||||
@@ -3875,6 +3933,11 @@ class $$ProductsTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get infiniteStock => $composableBuilder(
|
||||
column: $table.infiniteStock,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get priceInCents => $composableBuilder(
|
||||
column: $table.priceInCents,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -3950,6 +4013,11 @@ class $$ProductsTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get infiniteStock => $composableBuilder(
|
||||
column: $table.infiniteStock,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get priceInCents => $composableBuilder(
|
||||
column: $table.priceInCents,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -3994,6 +4062,11 @@ class $$ProductsTableAnnotationComposer
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get infiniteStock => $composableBuilder(
|
||||
column: $table.infiniteStock,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get priceInCents => $composableBuilder(
|
||||
column: $table.priceInCents,
|
||||
builder: (column) => column,
|
||||
@@ -4064,6 +4137,7 @@ class $$ProductsTableTableManager
|
||||
Value<String> category = const Value.absent(),
|
||||
Value<int> stockQuantity = const Value.absent(),
|
||||
Value<int> lowStockThreshold = const Value.absent(),
|
||||
Value<bool> infiniteStock = const Value.absent(),
|
||||
Value<int> priceInCents = const Value.absent(),
|
||||
Value<String?> imagePath = const Value.absent(),
|
||||
Value<bool> active = const Value.absent(),
|
||||
@@ -4074,6 +4148,7 @@ class $$ProductsTableTableManager
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: infiniteStock,
|
||||
priceInCents: priceInCents,
|
||||
imagePath: imagePath,
|
||||
active: active,
|
||||
@@ -4086,6 +4161,7 @@ class $$ProductsTableTableManager
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
Value<bool> infiniteStock = const Value.absent(),
|
||||
required int priceInCents,
|
||||
Value<String?> imagePath = const Value.absent(),
|
||||
Value<bool> active = const Value.absent(),
|
||||
@@ -4096,6 +4172,7 @@ class $$ProductsTableTableManager
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: infiniteStock,
|
||||
priceInCents: priceInCents,
|
||||
imagePath: imagePath,
|
||||
active: active,
|
||||
|
||||
@@ -21,6 +21,7 @@ class ProductNotFoundException implements Exception {
|
||||
/// Atomic stock updates shared by the product and tab services, so they can
|
||||
/// run inside either one's transaction.
|
||||
extension StockQueries on AppDatabase {
|
||||
/// Products with infinite stock are only checked for existence.
|
||||
Future<void> decreaseStock(String productId, int amount) async {
|
||||
_validateStockAmount(amount);
|
||||
|
||||
@@ -28,6 +29,7 @@ extension StockQueries on AppDatabase {
|
||||
await (update(products)..where(
|
||||
(row) =>
|
||||
row.id.equals(productId) &
|
||||
row.infiniteStock.not() &
|
||||
row.stockQuantity.isBiggerOrEqualValue(amount),
|
||||
))
|
||||
.write(
|
||||
@@ -38,28 +40,34 @@ extension StockQueries on AppDatabase {
|
||||
|
||||
if (updatedRows == 1) return;
|
||||
|
||||
final product = await (select(
|
||||
products,
|
||||
)..where((row) => row.id.equals(productId))).getSingleOrNull();
|
||||
|
||||
if (product == null) throw ProductNotFoundException(productId);
|
||||
|
||||
throw const InsufficientStockException();
|
||||
final product = await _findProduct(productId);
|
||||
if (!product.infiniteStock) throw const InsufficientStockException();
|
||||
}
|
||||
|
||||
Future<void> increaseStock(String productId, int amount) async {
|
||||
_validateStockAmount(amount);
|
||||
|
||||
final updatedRows =
|
||||
await (update(
|
||||
products,
|
||||
)..where((row) => row.id.equals(productId))).write(
|
||||
ProductsCompanion.custom(
|
||||
stockQuantity: products.stockQuantity + Variable(amount),
|
||||
),
|
||||
);
|
||||
await (update(products)..where(
|
||||
(row) => row.id.equals(productId) & row.infiniteStock.not(),
|
||||
))
|
||||
.write(
|
||||
ProductsCompanion.custom(
|
||||
stockQuantity: products.stockQuantity + Variable(amount),
|
||||
),
|
||||
);
|
||||
|
||||
if (updatedRows != 1) throw ProductNotFoundException(productId);
|
||||
if (updatedRows != 1) await _findProduct(productId);
|
||||
}
|
||||
|
||||
Future<ProductRow> _findProduct(String productId) async {
|
||||
final product = await (select(
|
||||
products,
|
||||
)..where((row) => row.id.equals(productId))).getSingleOrNull();
|
||||
|
||||
if (product == null) throw ProductNotFoundException(productId);
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
void _validateStockAmount(int amount) {
|
||||
|
||||
+20
-13
@@ -27,6 +27,11 @@
|
||||
"@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}},
|
||||
"searchByName": "Search by name…",
|
||||
"clearSearch": "Clear search",
|
||||
"openNewTabDescription": "Add a customer or group to the bar",
|
||||
"tabOpened": "Tab opened for {name}",
|
||||
"@tabOpened": {"placeholders": {"name": {"type": "String"}}},
|
||||
"searchTabs": "Search tabs",
|
||||
"closeSearch": "Close search",
|
||||
"allCustomers": "All customers",
|
||||
"customer": "Customer",
|
||||
"noClosedTabs": "No closed tabs yet",
|
||||
@@ -102,6 +107,10 @@
|
||||
"closeTab": "Close tab",
|
||||
"productStockSummary": "{category} • {price} • Stock: {stock}",
|
||||
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}},
|
||||
"infiniteStock": "Infinite stock",
|
||||
"infiniteStockDescription": "Never runs out, the stock isn't counted",
|
||||
"productInfiniteStockSummary": "{category} • {price} • Infinite stock",
|
||||
"@productInfiniteStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}}},
|
||||
"outOfStock": "OUT OF STOCK",
|
||||
"tabsYouCloseAppearHere": "Tabs you close will show up here.",
|
||||
"enterPinSubtitle": "You’ll use this to unlock the app",
|
||||
@@ -146,13 +155,8 @@
|
||||
"@versionAvailable": {"placeholders": {"version": {"type": "String"}}},
|
||||
"later": "Later",
|
||||
"update": "Update",
|
||||
"updateReady": "Update ready",
|
||||
"updateReadyDescription": "The update has been downloaded and installed. Restart the app now to apply the changes.",
|
||||
"restartApp": "Restart app",
|
||||
"updateFailed": "Update failed",
|
||||
"retry": "Retry",
|
||||
"updating": "Updating",
|
||||
"installing": "Installing",
|
||||
"doNotCloseDuringUpdate": "Do not close the app during the update",
|
||||
"cancelUpdate": "Cancel update?",
|
||||
"cancelUpdateDescription": "The update is in progress. If you leave now, the app may become unstable.",
|
||||
@@ -160,14 +164,19 @@
|
||||
"cancelUpdateAction": "Cancel update",
|
||||
"preparingDownload": "Preparing download…",
|
||||
"simulatedDownloadError": "Simulated download error (network timeout)",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloading": "Downloading {percent}%",
|
||||
"@downloading": {"placeholders": {"percent": {"type": "String"}}},
|
||||
"installingUpdate": "Installing update…",
|
||||
"updateInstalledRestarting": "Update installed! Restarting…",
|
||||
"installationFailed": "Installation failed",
|
||||
"updateAlreadyInProgress": "Update already in progress",
|
||||
"updateAlreadyRunning": "Update already running",
|
||||
"updatingTo": "Updating to {version}",
|
||||
"@updatingTo": {"placeholders": {"version": {"type": "String"}}},
|
||||
"downloadingUpdate": "Downloading update…",
|
||||
"confirmInstallation": "Confirm the installation",
|
||||
"confirmInstallationDescription": "Confirm the update in the Android prompt, then open KoolTap again.",
|
||||
"openInstallerAgain": "Open installer again",
|
||||
"whatsNew": "What's new",
|
||||
"updateDownloadFailed": "Could not download the update. Check the internet connection and try again.",
|
||||
"updateChecksumFailed": "The downloaded update is damaged. Try again.",
|
||||
"updatePermissionDenied": "KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.",
|
||||
"updateInstallFailed": "The update could not be installed.",
|
||||
"genericError": "An unexpected error occurred.",
|
||||
"couldNotLoadBar": "Could not load bar screen.",
|
||||
"couldNotCreateTabMessage": "Could not create tab.",
|
||||
@@ -186,8 +195,6 @@
|
||||
"currentPinIncorrect": "Current PIN is incorrect.",
|
||||
"couldNotLoadSettings": "Could not load settings.",
|
||||
"couldNotSaveSettings": "Could not save settings.",
|
||||
"downloadFailedWithDetail": "Download failed.",
|
||||
"installationFailedWithDetail": "Installation failed.",
|
||||
"devMenu": "Dev Menu",
|
||||
"dataManagement": "Data Management",
|
||||
"clearOpenTabs": "Clear all open tabs",
|
||||
|
||||
@@ -254,6 +254,30 @@ abstract class AppLocalizations {
|
||||
/// **'Clear search'**
|
||||
String get clearSearch;
|
||||
|
||||
/// No description provided for @openNewTabDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a customer or group to the bar'**
|
||||
String get openNewTabDescription;
|
||||
|
||||
/// No description provided for @tabOpened.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tab opened for {name}'**
|
||||
String tabOpened(String name);
|
||||
|
||||
/// No description provided for @searchTabs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search tabs'**
|
||||
String get searchTabs;
|
||||
|
||||
/// No description provided for @closeSearch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close search'**
|
||||
String get closeSearch;
|
||||
|
||||
/// No description provided for @allCustomers.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -674,6 +698,24 @@ abstract class AppLocalizations {
|
||||
/// **'{category} • {price} • Stock: {stock}'**
|
||||
String productStockSummary(String category, String price, int stock);
|
||||
|
||||
/// No description provided for @infiniteStock.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Infinite stock'**
|
||||
String get infiniteStock;
|
||||
|
||||
/// No description provided for @infiniteStockDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never runs out, the stock isn\'t counted'**
|
||||
String get infiniteStockDescription;
|
||||
|
||||
/// No description provided for @productInfiniteStockSummary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{category} • {price} • Infinite stock'**
|
||||
String productInfiniteStockSummary(String category, String price);
|
||||
|
||||
/// No description provided for @outOfStock.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -920,24 +962,6 @@ abstract class AppLocalizations {
|
||||
/// **'Update'**
|
||||
String get update;
|
||||
|
||||
/// No description provided for @updateReady.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update ready'**
|
||||
String get updateReady;
|
||||
|
||||
/// No description provided for @updateReadyDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The update has been downloaded and installed. Restart the app now to apply the changes.'**
|
||||
String get updateReadyDescription;
|
||||
|
||||
/// No description provided for @restartApp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Restart app'**
|
||||
String get restartApp;
|
||||
|
||||
/// No description provided for @updateFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -950,18 +974,6 @@ abstract class AppLocalizations {
|
||||
/// **'Retry'**
|
||||
String get retry;
|
||||
|
||||
/// No description provided for @updating.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Updating'**
|
||||
String get updating;
|
||||
|
||||
/// No description provided for @installing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Installing'**
|
||||
String get installing;
|
||||
|
||||
/// No description provided for @doNotCloseDuringUpdate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1004,47 +1016,77 @@ abstract class AppLocalizations {
|
||||
/// **'Simulated download error (network timeout)'**
|
||||
String get simulatedDownloadError;
|
||||
|
||||
/// No description provided for @downloadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download failed'**
|
||||
String get downloadFailed;
|
||||
|
||||
/// No description provided for @downloading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading {percent}%'**
|
||||
String downloading(String percent);
|
||||
|
||||
/// No description provided for @installingUpdate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Installing update…'**
|
||||
String get installingUpdate;
|
||||
|
||||
/// No description provided for @updateInstalledRestarting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update installed! Restarting…'**
|
||||
String get updateInstalledRestarting;
|
||||
|
||||
/// No description provided for @installationFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Installation failed'**
|
||||
String get installationFailed;
|
||||
|
||||
/// No description provided for @updateAlreadyInProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update already in progress'**
|
||||
String get updateAlreadyInProgress;
|
||||
|
||||
/// No description provided for @updateAlreadyRunning.
|
||||
/// No description provided for @updatingTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update already running'**
|
||||
String get updateAlreadyRunning;
|
||||
/// **'Updating to {version}'**
|
||||
String updatingTo(String version);
|
||||
|
||||
/// No description provided for @downloadingUpdate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading update…'**
|
||||
String get downloadingUpdate;
|
||||
|
||||
/// No description provided for @confirmInstallation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Confirm the installation'**
|
||||
String get confirmInstallation;
|
||||
|
||||
/// No description provided for @confirmInstallationDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Confirm the update in the Android prompt, then open KoolTap again.'**
|
||||
String get confirmInstallationDescription;
|
||||
|
||||
/// No description provided for @openInstallerAgain.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open installer again'**
|
||||
String get openInstallerAgain;
|
||||
|
||||
/// No description provided for @whatsNew.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What\'s new'**
|
||||
String get whatsNew;
|
||||
|
||||
/// No description provided for @updateDownloadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not download the update. Check the internet connection and try again.'**
|
||||
String get updateDownloadFailed;
|
||||
|
||||
/// No description provided for @updateChecksumFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The downloaded update is damaged. Try again.'**
|
||||
String get updateChecksumFailed;
|
||||
|
||||
/// No description provided for @updatePermissionDenied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.'**
|
||||
String get updatePermissionDenied;
|
||||
|
||||
/// No description provided for @updateInstallFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The update could not be installed.'**
|
||||
String get updateInstallFailed;
|
||||
|
||||
/// No description provided for @genericError.
|
||||
///
|
||||
@@ -1154,18 +1196,6 @@ abstract class AppLocalizations {
|
||||
/// **'Could not save settings.'**
|
||||
String get couldNotSaveSettings;
|
||||
|
||||
/// No description provided for @downloadFailedWithDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download failed.'**
|
||||
String get downloadFailedWithDetail;
|
||||
|
||||
/// No description provided for @installationFailedWithDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Installation failed.'**
|
||||
String get installationFailedWithDetail;
|
||||
|
||||
/// No description provided for @devMenu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -91,6 +91,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get clearSearch => 'Clear search';
|
||||
|
||||
@override
|
||||
String get openNewTabDescription => 'Add a customer or group to the bar';
|
||||
|
||||
@override
|
||||
String tabOpened(String name) {
|
||||
return 'Tab opened for $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchTabs => 'Search tabs';
|
||||
|
||||
@override
|
||||
String get closeSearch => 'Close search';
|
||||
|
||||
@override
|
||||
String get allCustomers => 'All customers';
|
||||
|
||||
@@ -325,6 +339,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$category • $price • Stock: $stock';
|
||||
}
|
||||
|
||||
@override
|
||||
String get infiniteStock => 'Infinite stock';
|
||||
|
||||
@override
|
||||
String get infiniteStockDescription =>
|
||||
'Never runs out, the stock isn\'t counted';
|
||||
|
||||
@override
|
||||
String productInfiniteStockSummary(String category, String price) {
|
||||
return '$category • $price • Infinite stock';
|
||||
}
|
||||
|
||||
@override
|
||||
String get outOfStock => 'OUT OF STOCK';
|
||||
|
||||
@@ -455,28 +481,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get update => 'Update';
|
||||
|
||||
@override
|
||||
String get updateReady => 'Update ready';
|
||||
|
||||
@override
|
||||
String get updateReadyDescription =>
|
||||
'The update has been downloaded and installed. Restart the app now to apply the changes.';
|
||||
|
||||
@override
|
||||
String get restartApp => 'Restart app';
|
||||
|
||||
@override
|
||||
String get updateFailed => 'Update failed';
|
||||
|
||||
@override
|
||||
String get retry => 'Retry';
|
||||
|
||||
@override
|
||||
String get updating => 'Updating';
|
||||
|
||||
@override
|
||||
String get installing => 'Installing';
|
||||
|
||||
@override
|
||||
String get doNotCloseDuringUpdate => 'Do not close the app during the update';
|
||||
|
||||
@@ -500,28 +510,47 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get simulatedDownloadError =>
|
||||
'Simulated download error (network timeout)';
|
||||
|
||||
@override
|
||||
String get downloadFailed => 'Download failed';
|
||||
|
||||
@override
|
||||
String downloading(String percent) {
|
||||
return 'Downloading $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get installingUpdate => 'Installing update…';
|
||||
|
||||
@override
|
||||
String get updateInstalledRestarting => 'Update installed! Restarting…';
|
||||
|
||||
@override
|
||||
String get installationFailed => 'Installation failed';
|
||||
|
||||
@override
|
||||
String get updateAlreadyInProgress => 'Update already in progress';
|
||||
|
||||
@override
|
||||
String get updateAlreadyRunning => 'Update already running';
|
||||
String updatingTo(String version) {
|
||||
return 'Updating to $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadingUpdate => 'Downloading update…';
|
||||
|
||||
@override
|
||||
String get confirmInstallation => 'Confirm the installation';
|
||||
|
||||
@override
|
||||
String get confirmInstallationDescription =>
|
||||
'Confirm the update in the Android prompt, then open KoolTap again.';
|
||||
|
||||
@override
|
||||
String get openInstallerAgain => 'Open installer again';
|
||||
|
||||
@override
|
||||
String get whatsNew => 'What\'s new';
|
||||
|
||||
@override
|
||||
String get updateDownloadFailed =>
|
||||
'Could not download the update. Check the internet connection and try again.';
|
||||
|
||||
@override
|
||||
String get updateChecksumFailed =>
|
||||
'The downloaded update is damaged. Try again.';
|
||||
|
||||
@override
|
||||
String get updatePermissionDenied =>
|
||||
'KoolTap is not allowed to install updates. Allow \"Install unknown apps\" for KoolTap in the Android settings.';
|
||||
|
||||
@override
|
||||
String get updateInstallFailed => 'The update could not be installed.';
|
||||
|
||||
@override
|
||||
String get genericError => 'An unexpected error occurred.';
|
||||
@@ -577,12 +606,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get couldNotSaveSettings => 'Could not save settings.';
|
||||
|
||||
@override
|
||||
String get downloadFailedWithDetail => 'Download failed.';
|
||||
|
||||
@override
|
||||
String get installationFailedWithDetail => 'Installation failed.';
|
||||
|
||||
@override
|
||||
String get devMenu => 'Dev Menu';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/app_error.dart';
|
||||
import '../models/update_failure.dart';
|
||||
import 'app_localizations.dart';
|
||||
|
||||
extension AppLocalizationsHelpers on AppLocalizations {
|
||||
@@ -44,47 +45,18 @@ extension AppLocalizationsHelpers on AppLocalizations {
|
||||
};
|
||||
}
|
||||
|
||||
/// Update messages still come from the OTA plugin as plain strings.
|
||||
String _updateMessage(String? message) {
|
||||
return switch (message) {
|
||||
'Update failed' => updateFailed,
|
||||
'Download failed' => downloadFailed,
|
||||
'Installation failed' => installationFailed,
|
||||
'Update already in progress' => updateAlreadyInProgress,
|
||||
_ => message ?? genericError,
|
||||
String updateFailureText(UpdateFailure? failure) {
|
||||
return switch (failure) {
|
||||
UpdateFailure.download => updateDownloadFailed,
|
||||
UpdateFailure.checksum => updateChecksumFailed,
|
||||
UpdateFailure.permission => updatePermissionDenied,
|
||||
UpdateFailure.install => updateInstallFailed,
|
||||
UpdateFailure.alreadyRunning => updateAlreadyInProgress,
|
||||
UpdateFailure.simulated => simulatedDownloadError,
|
||||
UpdateFailure.unknown || null => updateFailed,
|
||||
};
|
||||
}
|
||||
|
||||
String updateStatus(String status) {
|
||||
if (status.startsWith('Downloading ')) {
|
||||
return downloading(status.substring('Downloading '.length));
|
||||
}
|
||||
|
||||
return switch (status) {
|
||||
'Preparing download…' => preparingDownload,
|
||||
'Download failed' => downloadFailed,
|
||||
'Installing update…' => installingUpdate,
|
||||
'Update installed! Restarting…' => updateInstalledRestarting,
|
||||
'Installation failed' => installationFailed,
|
||||
'Update already running' => updateAlreadyRunning,
|
||||
_ => status,
|
||||
};
|
||||
}
|
||||
|
||||
String updateError(String? message) {
|
||||
if (message == null) return updateFailed;
|
||||
if (message.startsWith('Download failed')) return downloadFailedWithDetail;
|
||||
if (message.startsWith('Installation failed')) {
|
||||
return installationFailedWithDetail;
|
||||
}
|
||||
if (message == 'Simulated download error (network timeout)') {
|
||||
return simulatedDownloadError;
|
||||
}
|
||||
if (message == 'Update already in progress') return updateAlreadyInProgress;
|
||||
|
||||
return _updateMessage(message);
|
||||
}
|
||||
|
||||
String devAction(String? message) {
|
||||
if (message == null) return '';
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get openOrSelectTab => 'Open of selecteer eerst een poef.';
|
||||
|
||||
@override
|
||||
String get openNewTab => 'Nieuw poef openen';
|
||||
String get openNewTab => 'Nieuwe poef openen';
|
||||
|
||||
@override
|
||||
String get customerGroupName => 'Naam klant / groep';
|
||||
@@ -90,6 +90,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get clearSearch => 'Zoekopdracht wissen';
|
||||
|
||||
@override
|
||||
String get openNewTabDescription =>
|
||||
'Een klant of groep toevoegen aan de kassa';
|
||||
|
||||
@override
|
||||
String tabOpened(String name) {
|
||||
return 'Poef geopend voor $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchTabs => 'Poefs zoeken';
|
||||
|
||||
@override
|
||||
String get closeSearch => 'Zoeken sluiten';
|
||||
|
||||
@override
|
||||
String get allCustomers => 'Alle klanten';
|
||||
|
||||
@@ -325,6 +340,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '$category • $price • Voorraad: $stock';
|
||||
}
|
||||
|
||||
@override
|
||||
String get infiniteStock => 'Oneindige voorraad';
|
||||
|
||||
@override
|
||||
String get infiniteStockDescription =>
|
||||
'Raakt nooit op, de voorraad wordt niet geteld';
|
||||
|
||||
@override
|
||||
String productInfiniteStockSummary(String category, String price) {
|
||||
return '$category • $price • Oneindige voorraad';
|
||||
}
|
||||
|
||||
@override
|
||||
String get outOfStock => 'UITVERKOCHT';
|
||||
|
||||
@@ -457,28 +484,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get update => 'Updaten';
|
||||
|
||||
@override
|
||||
String get updateReady => 'Update klaar';
|
||||
|
||||
@override
|
||||
String get updateReadyDescription =>
|
||||
'De update is gedownload en geïnstalleerd. Start de app nu opnieuw om de wijzigingen toe te passen.';
|
||||
|
||||
@override
|
||||
String get restartApp => 'App opnieuw starten';
|
||||
|
||||
@override
|
||||
String get updateFailed => 'Update mislukt';
|
||||
|
||||
@override
|
||||
String get retry => 'Opnieuw proberen';
|
||||
|
||||
@override
|
||||
String get updating => 'Updaten';
|
||||
|
||||
@override
|
||||
String get installing => 'Installeren';
|
||||
|
||||
@override
|
||||
String get doNotCloseDuringUpdate => 'Sluit de app niet tijdens de update';
|
||||
|
||||
@@ -502,29 +513,47 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get simulatedDownloadError =>
|
||||
'Gesimuleerde downloadfout (netwerktime-out)';
|
||||
|
||||
@override
|
||||
String get downloadFailed => 'Download mislukt';
|
||||
|
||||
@override
|
||||
String downloading(String percent) {
|
||||
return 'Downloaden $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get installingUpdate => 'Update installeren…';
|
||||
|
||||
@override
|
||||
String get updateInstalledRestarting =>
|
||||
'Update geïnstalleerd! Opnieuw starten…';
|
||||
|
||||
@override
|
||||
String get installationFailed => 'Installatie mislukt';
|
||||
|
||||
@override
|
||||
String get updateAlreadyInProgress => 'Update is al bezig';
|
||||
|
||||
@override
|
||||
String get updateAlreadyRunning => 'Update wordt al uitgevoerd';
|
||||
String updatingTo(String version) {
|
||||
return 'Updaten naar $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadingUpdate => 'Update downloaden…';
|
||||
|
||||
@override
|
||||
String get confirmInstallation => 'Bevestig de installatie';
|
||||
|
||||
@override
|
||||
String get confirmInstallationDescription =>
|
||||
'Bevestig de update in het Android-venster en open KoolTap daarna opnieuw.';
|
||||
|
||||
@override
|
||||
String get openInstallerAgain => 'Installatie opnieuw openen';
|
||||
|
||||
@override
|
||||
String get whatsNew => 'Wat is er nieuw';
|
||||
|
||||
@override
|
||||
String get updateDownloadFailed =>
|
||||
'Kon de update niet downloaden. Controleer de internetverbinding en probeer opnieuw.';
|
||||
|
||||
@override
|
||||
String get updateChecksumFailed =>
|
||||
'De gedownloade update is beschadigd. Probeer opnieuw.';
|
||||
|
||||
@override
|
||||
String get updatePermissionDenied =>
|
||||
'KoolTap mag geen updates installeren. Sta \"Onbekende apps installeren\" toe voor KoolTap in de Android-instellingen.';
|
||||
|
||||
@override
|
||||
String get updateInstallFailed => 'De update kon niet worden geïnstalleerd.';
|
||||
|
||||
@override
|
||||
String get genericError => 'Er is een onverwachte fout opgetreden.';
|
||||
@@ -584,12 +613,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get couldNotSaveSettings =>
|
||||
'Instellingen konden niet worden opgeslagen.';
|
||||
|
||||
@override
|
||||
String get downloadFailedWithDetail => 'Download mislukt.';
|
||||
|
||||
@override
|
||||
String get installationFailedWithDetail => 'Installatie mislukt.';
|
||||
|
||||
@override
|
||||
String get devMenu => 'Ontwikkelaarsmenu';
|
||||
|
||||
@@ -659,10 +682,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get testingHelpers => 'Testhelpers';
|
||||
|
||||
@override
|
||||
String get createTestTab => 'Testtabblad maken';
|
||||
String get createTestTab => 'Testpoef maken';
|
||||
|
||||
@override
|
||||
String get addRandomItems => 'Tabblad met willekeurige items toevoegen';
|
||||
String get addRandomItems => 'Poef met willekeurige items toevoegen';
|
||||
|
||||
@override
|
||||
String get addTestProducts => '100 testproducten toevoegen';
|
||||
@@ -754,11 +777,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get categoryOther => 'Overig';
|
||||
|
||||
@override
|
||||
String get devActionClearedOpenTabs => 'Alle open tabbladen gewist';
|
||||
String get devActionClearedOpenTabs => 'Alle open poefs gewist';
|
||||
|
||||
@override
|
||||
String get devActionClearedHistory =>
|
||||
'Geschiedenis van gesloten tabbladen gewist';
|
||||
'Geschiedenis van gesloten poefs gewist';
|
||||
|
||||
@override
|
||||
String get devActionClearedProducts => 'Alle producten gewist';
|
||||
@@ -796,7 +819,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String devActionTestTab(int count) {
|
||||
return 'Testtabblad gemaakt met $count items';
|
||||
return 'Testpoef gemaakt met $count items';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+26
-19
@@ -19,7 +19,7 @@
|
||||
"importNamesDescription": "Bereid een lijst met namen voor om later als poefs te gebruiken.",
|
||||
"importPlaceholder": "Namen importeren is hier later beschikbaar.",
|
||||
"openOrSelectTab": "Open of selecteer eerst een poef.",
|
||||
"openNewTab": "Nieuw poef openen",
|
||||
"openNewTab": "Nieuwe poef openen",
|
||||
"customerGroupName": "Naam klant / groep",
|
||||
"cancel": "Annuleren",
|
||||
"openTab": "Poef openen",
|
||||
@@ -27,6 +27,11 @@
|
||||
"@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}},
|
||||
"searchByName": "Zoeken op naam…",
|
||||
"clearSearch": "Zoekopdracht wissen",
|
||||
"openNewTabDescription": "Een klant of groep toevoegen aan de kassa",
|
||||
"tabOpened": "Poef geopend voor {name}",
|
||||
"@tabOpened": {"placeholders": {"name": {"type": "String"}}},
|
||||
"searchTabs": "Poefs zoeken",
|
||||
"closeSearch": "Zoeken sluiten",
|
||||
"allCustomers": "Alle klanten",
|
||||
"customer": "Klant",
|
||||
"noClosedTabs": "Nog geen gesloten poefs",
|
||||
@@ -102,6 +107,10 @@
|
||||
"closeTab": "Poef sluiten",
|
||||
"productStockSummary": "{category} • {price} • Voorraad: {stock}",
|
||||
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}},
|
||||
"infiniteStock": "Oneindige voorraad",
|
||||
"infiniteStockDescription": "Raakt nooit op, de voorraad wordt niet geteld",
|
||||
"productInfiniteStockSummary": "{category} • {price} • Oneindige voorraad",
|
||||
"@productInfiniteStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}}},
|
||||
"outOfStock": "UITVERKOCHT",
|
||||
"tabsYouCloseAppearHere": "Gesloten poefs verschijnen hier.",
|
||||
"enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen",
|
||||
@@ -146,13 +155,8 @@
|
||||
"@versionAvailable": {"placeholders": {"version": {"type": "String"}}},
|
||||
"later": "Later",
|
||||
"update": "Updaten",
|
||||
"updateReady": "Update klaar",
|
||||
"updateReadyDescription": "De update is gedownload en geïnstalleerd. Start de app nu opnieuw om de wijzigingen toe te passen.",
|
||||
"restartApp": "App opnieuw starten",
|
||||
"updateFailed": "Update mislukt",
|
||||
"retry": "Opnieuw proberen",
|
||||
"updating": "Updaten",
|
||||
"installing": "Installeren",
|
||||
"doNotCloseDuringUpdate": "Sluit de app niet tijdens de update",
|
||||
"cancelUpdate": "Update annuleren?",
|
||||
"cancelUpdateDescription": "De update is bezig. Als je nu weggaat, kan de app instabiel worden.",
|
||||
@@ -160,14 +164,19 @@
|
||||
"cancelUpdateAction": "Update annuleren",
|
||||
"preparingDownload": "Download voorbereiden…",
|
||||
"simulatedDownloadError": "Gesimuleerde downloadfout (netwerktime-out)",
|
||||
"downloadFailed": "Download mislukt",
|
||||
"downloading": "Downloaden {percent}%",
|
||||
"@downloading": {"placeholders": {"percent": {"type": "String"}}},
|
||||
"installingUpdate": "Update installeren…",
|
||||
"updateInstalledRestarting": "Update geïnstalleerd! Opnieuw starten…",
|
||||
"installationFailed": "Installatie mislukt",
|
||||
"updateAlreadyInProgress": "Update is al bezig",
|
||||
"updateAlreadyRunning": "Update wordt al uitgevoerd",
|
||||
"updatingTo": "Updaten naar {version}",
|
||||
"@updatingTo": {"placeholders": {"version": {"type": "String"}}},
|
||||
"downloadingUpdate": "Update downloaden…",
|
||||
"confirmInstallation": "Bevestig de installatie",
|
||||
"confirmInstallationDescription": "Bevestig de update in het Android-venster en open KoolTap daarna opnieuw.",
|
||||
"openInstallerAgain": "Installatie opnieuw openen",
|
||||
"whatsNew": "Wat is er nieuw",
|
||||
"updateDownloadFailed": "Kon de update niet downloaden. Controleer de internetverbinding en probeer opnieuw.",
|
||||
"updateChecksumFailed": "De gedownloade update is beschadigd. Probeer opnieuw.",
|
||||
"updatePermissionDenied": "KoolTap mag geen updates installeren. Sta \"Onbekende apps installeren\" toe voor KoolTap in de Android-instellingen.",
|
||||
"updateInstallFailed": "De update kon niet worden geïnstalleerd.",
|
||||
"genericError": "Er is een onverwachte fout opgetreden.",
|
||||
"couldNotLoadBar": "Barkassa kon niet worden geladen.",
|
||||
"couldNotCreateTabMessage": "Poef kon niet worden aangemaakt.",
|
||||
@@ -186,8 +195,6 @@
|
||||
"currentPinIncorrect": "Huidige PIN is onjuist.",
|
||||
"couldNotLoadSettings": "Instellingen konden niet worden geladen.",
|
||||
"couldNotSaveSettings": "Instellingen konden niet worden opgeslagen.",
|
||||
"downloadFailedWithDetail": "Download mislukt.",
|
||||
"installationFailedWithDetail": "Installatie mislukt.",
|
||||
"devMenu": "Ontwikkelaarsmenu",
|
||||
"dataManagement": "Databeheer",
|
||||
"clearOpenTabs": "Alle open poefs wissen",
|
||||
@@ -210,8 +217,8 @@
|
||||
"simulateUpdateDownload": "Updatedownload simuleren",
|
||||
"openDownloadProgress": "Downloadvoortgang openen",
|
||||
"testingHelpers": "Testhelpers",
|
||||
"createTestTab": "Testtabblad maken",
|
||||
"addRandomItems": "Tabblad met willekeurige items toevoegen",
|
||||
"createTestTab": "Testpoef maken",
|
||||
"addRandomItems": "Poef met willekeurige items toevoegen",
|
||||
"addTestProducts": "100 testproducten toevoegen",
|
||||
"stressTestGrid": "Productraster stresstesten",
|
||||
"simulateLowStock": "Lage voorraad simuleren",
|
||||
@@ -240,8 +247,8 @@
|
||||
"categoryCoffee": "Koffie",
|
||||
"categoryTea": "Thee",
|
||||
"categoryOther": "Overig",
|
||||
"devActionClearedOpenTabs": "Alle open tabbladen gewist",
|
||||
"devActionClearedHistory": "Geschiedenis van gesloten tabbladen gewist",
|
||||
"devActionClearedOpenTabs": "Alle open poefs gewist",
|
||||
"devActionClearedHistory": "Geschiedenis van gesloten poefs gewist",
|
||||
"devActionClearedProducts": "Alle producten gewist",
|
||||
"devActionSeededProducts": "{count} producten toegevoegd",
|
||||
"@devActionSeededProducts": {"placeholders": {"count": {"type": "int"}}},
|
||||
@@ -254,7 +261,7 @@
|
||||
"devActionCacheCleared": "Afbeeldingencache gewist (geen actie in debugmodus)",
|
||||
"devActionLowStock": "Lage voorraad gesimuleerd voor {count} producten",
|
||||
"@devActionLowStock": {"placeholders": {"count": {"type": "int"}}},
|
||||
"devActionTestTab": "Testtabblad gemaakt met {count} items",
|
||||
"devActionTestTab": "Testpoef gemaakt met {count} items",
|
||||
"@devActionTestTab": {"placeholders": {"count": {"type": "int"}}},
|
||||
"devActionTestProducts": "{count} testproducten toegevoegd",
|
||||
"@devActionTestProducts": {"placeholders": {"count": {"type": "int"}}},
|
||||
|
||||
+11
-1
@@ -4,6 +4,9 @@ class Product {
|
||||
final String category;
|
||||
final int stockQuantity;
|
||||
final int lowStockThreshold;
|
||||
|
||||
/// Never runs out; [stockQuantity] is left untouched by sales.
|
||||
final bool infiniteStock;
|
||||
final int priceInCents;
|
||||
final String? imagePath;
|
||||
final bool active;
|
||||
@@ -14,12 +17,15 @@ class Product {
|
||||
required this.category,
|
||||
required this.stockQuantity,
|
||||
required this.lowStockThreshold,
|
||||
this.infiniteStock = false,
|
||||
required this.priceInCents,
|
||||
this.imagePath,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
bool get isLowStock => stockQuantity <= lowStockThreshold;
|
||||
bool get isOutOfStock => !infiniteStock && stockQuantity <= 0;
|
||||
|
||||
bool get isLowStock => !infiniteStock && stockQuantity <= lowStockThreshold;
|
||||
|
||||
Product copyWith({
|
||||
String? id,
|
||||
@@ -27,6 +33,7 @@ class Product {
|
||||
String? category,
|
||||
int? stockQuantity,
|
||||
int? lowStockThreshold,
|
||||
bool? infiniteStock,
|
||||
int? priceInCents,
|
||||
String? imagePath,
|
||||
bool? active,
|
||||
@@ -37,6 +44,7 @@ class Product {
|
||||
category: category ?? this.category,
|
||||
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
|
||||
infiniteStock: infiniteStock ?? this.infiniteStock,
|
||||
priceInCents: priceInCents ?? this.priceInCents,
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
active: active ?? this.active,
|
||||
@@ -52,6 +60,7 @@ class Product {
|
||||
category == other.category &&
|
||||
stockQuantity == other.stockQuantity &&
|
||||
lowStockThreshold == other.lowStockThreshold &&
|
||||
infiniteStock == other.infiniteStock &&
|
||||
priceInCents == other.priceInCents &&
|
||||
imagePath == other.imagePath &&
|
||||
active == other.active;
|
||||
@@ -63,6 +72,7 @@ class Product {
|
||||
category,
|
||||
stockQuantity,
|
||||
lowStockThreshold,
|
||||
infiniteStock,
|
||||
priceInCents,
|
||||
imagePath,
|
||||
active,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Why an over-the-air update stopped. Views translate it with
|
||||
/// `AppLocalizations.updateFailureText`.
|
||||
enum UpdateFailure {
|
||||
download,
|
||||
checksum,
|
||||
permission,
|
||||
install,
|
||||
alreadyRunning,
|
||||
simulated,
|
||||
unknown,
|
||||
}
|
||||
@@ -21,6 +21,7 @@ abstract class ProductService {
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
bool infiniteStock = false,
|
||||
required int priceInCents,
|
||||
required String? imagePath,
|
||||
});
|
||||
@@ -47,6 +48,7 @@ class DriftProductService implements ProductService {
|
||||
category: row.category,
|
||||
stockQuantity: row.stockQuantity,
|
||||
lowStockThreshold: row.lowStockThreshold,
|
||||
infiniteStock: row.infiniteStock,
|
||||
priceInCents: row.priceInCents,
|
||||
imagePath: row.imagePath,
|
||||
active: row.active,
|
||||
@@ -89,6 +91,7 @@ class DriftProductService implements ProductService {
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
bool infiniteStock = false,
|
||||
required int priceInCents,
|
||||
required String? imagePath,
|
||||
}) async {
|
||||
@@ -101,6 +104,7 @@ class DriftProductService implements ProductService {
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: Value(infiniteStock),
|
||||
priceInCents: priceInCents,
|
||||
imagePath: Value(imagePath),
|
||||
),
|
||||
@@ -118,6 +122,7 @@ class DriftProductService implements ProductService {
|
||||
category: Value(product.category),
|
||||
stockQuantity: Value(product.stockQuantity),
|
||||
lowStockThreshold: Value(product.lowStockThreshold),
|
||||
infiniteStock: Value(product.infiniteStock),
|
||||
priceInCents: Value(product.priceInCents),
|
||||
imagePath: Value(product.imagePath),
|
||||
active: Value(product.active),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/// Letters of an A–Z index; names that don't start with a letter go under `#`.
|
||||
final indexLetters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), '#'];
|
||||
|
||||
const _accented = 'ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝŸ';
|
||||
const _unaccented = 'AAAAAACEEEEIIIINOOOOOUUUUYY';
|
||||
|
||||
/// Uppercase without accents, so "Émile" sorts and indexes under E.
|
||||
String _fold(String name) {
|
||||
return [
|
||||
for (final char in name.trim().toUpperCase().split(''))
|
||||
switch (_accented.indexOf(char)) {
|
||||
-1 => char,
|
||||
final index => _unaccented[index],
|
||||
},
|
||||
].join();
|
||||
}
|
||||
|
||||
String indexLetterOf(String name) {
|
||||
final folded = _fold(name);
|
||||
if (folded.isEmpty) return '#';
|
||||
|
||||
final first = folded[0];
|
||||
return indexLetters.contains(first) ? first : '#';
|
||||
}
|
||||
|
||||
/// A–Z regardless of case and accents, with the `#` names last.
|
||||
int compareByIndex(String a, String b) {
|
||||
final byLetter = indexLetters
|
||||
.indexOf(indexLetterOf(a))
|
||||
.compareTo(indexLetters.indexOf(indexLetterOf(b)));
|
||||
if (byLetter != 0) return byLetter;
|
||||
|
||||
final byName = _fold(a).compareTo(_fold(b));
|
||||
return byName != 0 ? byName : a.compareTo(b);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ class ProductListViewModel extends ChangeNotifier {
|
||||
required String category,
|
||||
required int stockQuantity,
|
||||
required int lowStockThreshold,
|
||||
required bool infiniteStock,
|
||||
required int priceInCents,
|
||||
required String? imagePath,
|
||||
}) {
|
||||
@@ -55,6 +56,7 @@ class ProductListViewModel extends ChangeNotifier {
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: infiniteStock,
|
||||
priceInCents: priceInCents,
|
||||
imagePath: imagePath,
|
||||
),
|
||||
|
||||
@@ -2,31 +2,45 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ota_update/ota_update.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../models/update_failure.dart';
|
||||
import '../utils/app_config.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
|
||||
enum OtaPhase { downloading, installing, done, error }
|
||||
enum UpdatePhase {
|
||||
downloading,
|
||||
installing,
|
||||
|
||||
/// Android's installer prompt is open; the app is replaced once the user
|
||||
/// confirms it, so there is no "done" state to show.
|
||||
waitingForInstaller,
|
||||
failed,
|
||||
}
|
||||
|
||||
class UpdateProgressViewModel extends ChangeNotifier {
|
||||
final UpdateInfo update;
|
||||
final bool simulate;
|
||||
final bool simulateError;
|
||||
|
||||
OtaPhase _phase = OtaPhase.downloading;
|
||||
double _progress = 0;
|
||||
String _statusText = 'Preparing download…';
|
||||
String? _errorMessage;
|
||||
bool _done = false;
|
||||
UpdatePhase _phase = UpdatePhase.downloading;
|
||||
double? _progress;
|
||||
UpdateFailure? _failure;
|
||||
String? _currentVersion;
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
StreamSubscription<OtaEvent>? _subscription;
|
||||
Timer? _simTimer;
|
||||
|
||||
OtaPhase get phase => _phase;
|
||||
double get progress => _progress;
|
||||
String get statusText => _statusText;
|
||||
String? get errorMessage => _errorMessage;
|
||||
UpdatePhase get phase => _phase;
|
||||
|
||||
/// Download progress from 0 to 1, or null while it is not known yet.
|
||||
double? get progress => _progress;
|
||||
UpdateFailure? get failure => _failure;
|
||||
String? get currentVersion => _currentVersion;
|
||||
|
||||
bool get isBusy =>
|
||||
_phase == UpdatePhase.downloading || _phase == UpdatePhase.installing;
|
||||
|
||||
UpdateProgressViewModel({
|
||||
required this.update,
|
||||
@@ -34,7 +48,18 @@ class UpdateProgressViewModel extends ChangeNotifier {
|
||||
this.simulateError = false,
|
||||
});
|
||||
|
||||
Future<void> start() async {
|
||||
void init() {
|
||||
unawaited(_loadCurrentVersion());
|
||||
start();
|
||||
}
|
||||
|
||||
void start() {
|
||||
cancel();
|
||||
_phase = UpdatePhase.downloading;
|
||||
_progress = null;
|
||||
_failure = null;
|
||||
notifyListeners();
|
||||
|
||||
if (simulate) {
|
||||
_runSimulation();
|
||||
return;
|
||||
@@ -44,8 +69,6 @@ class UpdateProgressViewModel extends ChangeNotifier {
|
||||
? update.download
|
||||
: '$kUpdateServerUrl${update.download}';
|
||||
|
||||
debugPrint('UpdateProgressViewModel: starting download from $url');
|
||||
|
||||
_subscription = OtaUpdate()
|
||||
.execute(
|
||||
url,
|
||||
@@ -55,118 +78,126 @@ class UpdateProgressViewModel extends ChangeNotifier {
|
||||
.listen(_onEvent, onError: _onError, onDone: _onDone);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_simTimer?.cancel();
|
||||
_simTimer = null;
|
||||
unawaited(_subscription?.cancel());
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentVersion() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_currentVersion = info.version;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('UpdateProgressViewModel: could not read version: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(OtaEvent event) {
|
||||
switch (event.status) {
|
||||
case OtaStatus.DOWNLOADING:
|
||||
final percent = double.tryParse(event.value ?? '') ?? 0;
|
||||
_progress = (percent / 100).clamp(0.0, 1.0);
|
||||
_phase = UpdatePhase.downloading;
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.INSTALLING:
|
||||
_progress = 1;
|
||||
_phase = UpdatePhase.installing;
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.INSTALLATION_DONE:
|
||||
_phase = UpdatePhase.waitingForInstaller;
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.DOWNLOAD_ERROR:
|
||||
_fail(UpdateFailure.download, event.value);
|
||||
case OtaStatus.CHECKSUM_ERROR:
|
||||
_fail(UpdateFailure.checksum, event.value);
|
||||
case OtaStatus.PERMISSION_NOT_GRANTED_ERROR:
|
||||
_fail(UpdateFailure.permission, event.value);
|
||||
case OtaStatus.INSTALLATION_ERROR:
|
||||
_fail(UpdateFailure.install, event.value);
|
||||
case OtaStatus.ALREADY_RUNNING_ERROR:
|
||||
_fail(UpdateFailure.alreadyRunning, event.value);
|
||||
case OtaStatus.INTERNAL_ERROR:
|
||||
_fail(UpdateFailure.unknown, event.value);
|
||||
case OtaStatus.CANCELED:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin closes the stream once it has handed the APK to Android's
|
||||
/// installer; closing earlier means it gave up without telling why.
|
||||
void _onDone() {
|
||||
_subscription = null;
|
||||
|
||||
switch (_phase) {
|
||||
case UpdatePhase.installing:
|
||||
_phase = UpdatePhase.waitingForInstaller;
|
||||
notifyListeners();
|
||||
case UpdatePhase.downloading:
|
||||
_fail(UpdateFailure.unknown, 'Update stream closed while downloading');
|
||||
case UpdatePhase.waitingForInstaller || UpdatePhase.failed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(Object error, StackTrace stackTrace) {
|
||||
debugPrint('UpdateProgressViewModel: stream error: $error');
|
||||
unawaited(Sentry.captureException(error, stackTrace: stackTrace));
|
||||
_fail(UpdateFailure.unknown, null, report: false);
|
||||
}
|
||||
|
||||
void _fail(UpdateFailure failure, String? detail, {bool report = true}) {
|
||||
debugPrint('UpdateProgressViewModel: $failure: $detail');
|
||||
if (report) {
|
||||
unawaited(
|
||||
Sentry.captureMessage(
|
||||
'OTA update failed ($failure, ${update.version}): $detail',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
cancel();
|
||||
_phase = UpdatePhase.failed;
|
||||
_failure = failure;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _runSimulation() {
|
||||
debugPrint('UpdateProgressViewModel: running simulated download');
|
||||
const totalSteps = 40;
|
||||
var step = 0;
|
||||
|
||||
int step = 0;
|
||||
const totalSteps = 20;
|
||||
const stepDuration = Duration(milliseconds: 500);
|
||||
|
||||
_simTimer = Timer.periodic(stepDuration, (timer) {
|
||||
_simTimer = Timer.periodic(const Duration(milliseconds: 150), (timer) {
|
||||
step++;
|
||||
|
||||
if (simulateError && step == 10) {
|
||||
if (simulateError && step == totalSteps ~/ 2) {
|
||||
_phase = UpdatePhase.failed;
|
||||
_failure = UpdateFailure.simulated;
|
||||
timer.cancel();
|
||||
_phase = OtaPhase.error;
|
||||
_errorMessage = 'Simulated download error (network timeout)';
|
||||
_statusText = 'Download failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (step < totalSteps) {
|
||||
_progress = step / totalSteps;
|
||||
_statusText = 'Downloading ${(_progress * 100).toStringAsFixed(0)}%';
|
||||
_phase = OtaPhase.downloading;
|
||||
notifyListeners();
|
||||
} else {
|
||||
timer.cancel();
|
||||
_startInstallPhase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startInstallPhase() {
|
||||
_phase = OtaPhase.installing;
|
||||
_progress = 1.0;
|
||||
_statusText = 'Installing update…';
|
||||
notifyListeners();
|
||||
|
||||
_simTimer = Timer(const Duration(seconds: 3), () {
|
||||
_phase = OtaPhase.done;
|
||||
_progress = 1.0;
|
||||
_statusText = 'Update installed! Restarting…';
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _onDone() {
|
||||
if (_done) return;
|
||||
_done = true;
|
||||
_phase = OtaPhase.done;
|
||||
_progress = 1.0;
|
||||
_statusText = 'Update installed! Restarting…';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(OtaEvent event) {
|
||||
switch (event.status) {
|
||||
case OtaStatus.DOWNLOADING:
|
||||
final raw = double.tryParse(event.value ?? '0') ?? 0;
|
||||
_progress = (raw / 100).clamp(0.0, 1.0);
|
||||
_statusText = 'Downloading ${_progress.toStringAsFixed(0)}%';
|
||||
_phase = OtaPhase.downloading;
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.INSTALLING:
|
||||
_phase = OtaPhase.installing;
|
||||
_progress = 1.0;
|
||||
_statusText = 'Installing update…';
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.INSTALLATION_DONE:
|
||||
_onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
case OtaStatus.DOWNLOAD_ERROR:
|
||||
_phase = OtaPhase.error;
|
||||
_errorMessage = 'Download failed: ${event.value}';
|
||||
_statusText = 'Download failed';
|
||||
Sentry.captureMessage('OTA download error: ${event.value}');
|
||||
timer.cancel();
|
||||
_progress = 1;
|
||||
_phase = UpdatePhase.installing;
|
||||
notifyListeners();
|
||||
|
||||
_simTimer = Timer(const Duration(seconds: 2), () {
|
||||
_phase = UpdatePhase.waitingForInstaller;
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.INSTALLATION_ERROR:
|
||||
_phase = OtaPhase.error;
|
||||
_errorMessage = 'Installation failed: ${event.value}';
|
||||
_statusText = 'Installation failed';
|
||||
Sentry.captureMessage('OTA installation error: ${event.value}');
|
||||
notifyListeners();
|
||||
|
||||
case OtaStatus.ALREADY_RUNNING_ERROR:
|
||||
_phase = OtaPhase.error;
|
||||
_errorMessage = 'Update already in progress';
|
||||
_statusText = 'Update already running';
|
||||
notifyListeners();
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(Object error, StackTrace stack) {
|
||||
debugPrint('UpdateProgressViewModel: stream error: $error');
|
||||
_phase = OtaPhase.error;
|
||||
_errorMessage = error.toString();
|
||||
_statusText = 'Update failed';
|
||||
Sentry.captureException(error, stackTrace: stack);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_simTimer?.cancel();
|
||||
_simTimer = null;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../models/remote_command.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import 'dialogs/new_tab_dialog.dart';
|
||||
|
||||
class AdminScreenView extends StatelessWidget {
|
||||
const AdminScreenView({super.key});
|
||||
@@ -17,6 +18,16 @@ class AdminScreenView extends StatelessWidget {
|
||||
adminPinViewModel.lock();
|
||||
}
|
||||
|
||||
Future<void> _openNewTab(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final name = await showNewTabDialog(context);
|
||||
|
||||
if (name == null || !context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name))));
|
||||
}
|
||||
|
||||
Future<void> _confirmUnlink(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -80,6 +91,12 @@ class AdminScreenView extends StatelessWidget {
|
||||
_AdminSection(
|
||||
title: l10n.adminTools,
|
||||
children: [
|
||||
_AdminTile(
|
||||
icon: Icons.person_add_alt_1_outlined,
|
||||
title: l10n.openNewTab,
|
||||
subtitle: l10n.openNewTabDescription,
|
||||
onTap: () => _openNewTab(context),
|
||||
),
|
||||
_AdminTile(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: l10n.products,
|
||||
|
||||
@@ -19,7 +19,6 @@ import '../../viewmodels/remote_sync_view_model.dart';
|
||||
import '../../viewmodels/settings_view_model.dart';
|
||||
import '../dialogs/close_tab_dialog.dart';
|
||||
import '../dialogs/edit_tab_dialog.dart';
|
||||
import '../dialogs/new_tab_dialog.dart';
|
||||
import 'product_grid.dart';
|
||||
import 'tab_panel.dart';
|
||||
|
||||
@@ -31,6 +30,7 @@ class BarScreenView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BarScreenViewState extends State<BarScreenView> {
|
||||
bool _showSearch = false;
|
||||
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
|
||||
|
||||
@override
|
||||
@@ -110,13 +110,21 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
final settings = context.watch<SettingsViewModel>().settings;
|
||||
final stockByProductId = {
|
||||
for (final product in productsViewModel.products)
|
||||
product.id: product.stockQuantity,
|
||||
if (!product.infiniteStock) product.id: product.stockQuantity,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.barTabs),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs,
|
||||
isSelected: _showSearch,
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
selectedIcon: const Icon(Icons.search_off_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.admin,
|
||||
onPressed: () => context.push('/admin'),
|
||||
@@ -190,7 +198,7 @@ class _BarScreenViewState extends State<BarScreenView> {
|
||||
selectedTab: viewModel.selectedTab,
|
||||
selectedTabId: viewModel.selectedTabId,
|
||||
stockByProductId: stockByProductId,
|
||||
onNewTabPressed: () => showNewTabDialog(context),
|
||||
showSearch: _showSearch,
|
||||
onTabSelected: viewModel.selectTab,
|
||||
onItemQuantityChanged: viewModel.changeItemQuantity,
|
||||
onCloseTabPressed: () => confirmCloseTab(context),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -111,33 +113,36 @@ class ProductGrid extends StatelessWidget {
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const horizontalPadding = 32.0;
|
||||
const verticalPadding = 28.0;
|
||||
const padding = EdgeInsets.fromLTRB(16, 12, 16, 16);
|
||||
const spacing = 12.0;
|
||||
const minTileHeight = 72.0;
|
||||
|
||||
// Exactly [rows] rows fill the height; the column count keeps
|
||||
// the tiles as close to square as the width allows
|
||||
final rowCount = rows.clamp(2, 6).toInt();
|
||||
final rowHeight =
|
||||
((constraints.maxHeight -
|
||||
verticalPadding -
|
||||
(rowCount - 1) * spacing) /
|
||||
rowCount)
|
||||
.clamp(96.0, 320.0)
|
||||
.toDouble();
|
||||
final columns =
|
||||
(((constraints.maxWidth - horizontalPadding + spacing) /
|
||||
(rowHeight + spacing))
|
||||
.ceil())
|
||||
.clamp(2, 8)
|
||||
.toInt();
|
||||
final tileHeight = max(
|
||||
minTileHeight,
|
||||
(constraints.maxHeight -
|
||||
padding.vertical -
|
||||
(rowCount - 1) * spacing) /
|
||||
rowCount,
|
||||
);
|
||||
final columns = max(
|
||||
1,
|
||||
((constraints.maxWidth - padding.horizontal + spacing) /
|
||||
(tileHeight + spacing))
|
||||
.round(),
|
||||
);
|
||||
|
||||
return GridView.builder(
|
||||
key: ValueKey('product-grid-$rows'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
key: ValueKey('product-grid-$rowCount'),
|
||||
padding: padding,
|
||||
itemCount: products.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
mainAxisExtent: tileHeight,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
|
||||
+395
-161
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../utils/name_index.dart';
|
||||
import 'selected_tab_details.dart';
|
||||
|
||||
class TabPanel extends StatefulWidget {
|
||||
@@ -12,7 +17,7 @@ class TabPanel extends StatefulWidget {
|
||||
final BarTab? selectedTab;
|
||||
final String? selectedTabId;
|
||||
final Map<String, int> stockByProductId;
|
||||
final VoidCallback onNewTabPressed;
|
||||
final bool showSearch;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
@@ -25,7 +30,7 @@ class TabPanel extends StatefulWidget {
|
||||
required this.selectedTab,
|
||||
required this.selectedTabId,
|
||||
required this.stockByProductId,
|
||||
required this.onNewTabPressed,
|
||||
required this.showSearch,
|
||||
required this.onTabSelected,
|
||||
required this.onItemQuantityChanged,
|
||||
required this.onCloseTabPressed,
|
||||
@@ -39,14 +44,76 @@ class TabPanel extends StatefulWidget {
|
||||
|
||||
class _TabPanelState extends State<TabPanel> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _tabsScrollController = ScrollController();
|
||||
String _searchQuery = '';
|
||||
|
||||
/// The letter under the finger while the index bar is pressed.
|
||||
String? _touchedLetter;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TabPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Hiding the search bar shouldn't leave the list silently filtered
|
||||
if (oldWidget.showSearch && !widget.showSearch) {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_tabsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Every tab row has the same height, so it follows from the scroll range.
|
||||
double? _tabExtent(int tabCount) {
|
||||
if (!_tabsScrollController.hasClients || tabCount == 0) return null;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
return (position.maxScrollExtent + position.viewportDimension) / tabCount;
|
||||
}
|
||||
|
||||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||||
/// lands on the next one.
|
||||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||||
if (letter != _touchedLetter) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
setState(() => _touchedLetter = letter);
|
||||
}
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
if (extent == null) return;
|
||||
|
||||
final position = _tabsScrollController.position;
|
||||
final start = indexLetters.indexOf(letter);
|
||||
final index = tabs.indexWhere(
|
||||
(tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start,
|
||||
);
|
||||
|
||||
_tabsScrollController.jumpTo(
|
||||
index == -1
|
||||
? position.maxScrollExtent
|
||||
: min(index * extent, position.maxScrollExtent),
|
||||
);
|
||||
}
|
||||
|
||||
String? _visibleLetter(List<BarTab> tabs) {
|
||||
if (tabs.isEmpty) return null;
|
||||
|
||||
final extent = _tabExtent(tabs.length);
|
||||
final index = extent == null
|
||||
? 0
|
||||
: (_tabsScrollController.offset / extent).round().clamp(
|
||||
0,
|
||||
tabs.length - 1,
|
||||
);
|
||||
|
||||
return indexLetterOf(tabs[index].customerName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -56,95 +123,115 @@ class _TabPanelState extends State<TabPanel> {
|
||||
return tab.customerName.toLowerCase().contains(
|
||||
_searchQuery.toLowerCase(),
|
||||
);
|
||||
}).toList();
|
||||
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchByName,
|
||||
prefixIcon: Icon(Icons.search_rounded, size: 20),
|
||||
suffixIcon: _searchQuery.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_rounded),
|
||||
tooltip: l10n.clearSearch,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 10),
|
||||
|
||||
IconButton.filled(
|
||||
onPressed: widget.onNewTabPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: l10n.openNewTab,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: OpenTabsList(
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
onTabSelected: widget.onTabSelected,
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
ListenableBuilder(
|
||||
listenable: _tabsScrollController,
|
||||
builder: (context, child) => _LetterIndexBar(
|
||||
availableLetters: {
|
||||
for (final tab in filteredTabs)
|
||||
indexLetterOf(tab.customerName),
|
||||
},
|
||||
activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs),
|
||||
isTouched: _touchedLetter != null,
|
||||
onLetterSelected: (letter) =>
|
||||
_jumpToLetter(letter, filteredTabs),
|
||||
onReleased: () => setState(() => _touchedLetter = null),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 28),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: widget.selectedTab == null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 36,
|
||||
color: scheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.selectOrOpenTab,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Fixed height, so hiding the search bar gives its space
|
||||
// to the names
|
||||
SizedBox(
|
||||
height: 246,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.showSearch) ...[
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchByName,
|
||||
prefixIcon: Icon(Icons.search_rounded, size: 20),
|
||||
suffixIcon: _searchQuery.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_rounded),
|
||||
tooltip: l10n.clearSearch,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SelectedTabDetails(
|
||||
tab: widget.selectedTab!,
|
||||
stockByProductId: widget.stockByProductId,
|
||||
onItemQuantityChanged: widget.onItemQuantityChanged,
|
||||
onCloseTabPressed: widget.onCloseTabPressed,
|
||||
Expanded(
|
||||
child: OpenTabsList(
|
||||
controller: _tabsScrollController,
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
onTabSelected: widget.onTabSelected,
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 28),
|
||||
|
||||
Expanded(
|
||||
child: widget.selectedTab == null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 36,
|
||||
color: scheme.onSurface.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.selectOrOpenTab,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SelectedTabDetails(
|
||||
tab: widget.selectedTab!,
|
||||
stockByProductId: widget.stockByProductId,
|
||||
onItemQuantityChanged: widget.onItemQuantityChanged,
|
||||
onCloseTabPressed: widget.onCloseTabPressed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -154,6 +241,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
}
|
||||
|
||||
class OpenTabsList extends StatelessWidget {
|
||||
final ScrollController? controller;
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
@@ -162,6 +250,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
|
||||
const OpenTabsList({
|
||||
super.key,
|
||||
this.controller,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
required this.onTabSelected,
|
||||
@@ -181,92 +270,44 @@ class OpenTabsList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
// A fixed row height lets the letter index work out where each tab is
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: tabs.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
// The selected card's border is thickest, so it sets the height
|
||||
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final selected = tab.id == selectedTabId;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Slidable(
|
||||
key: ValueKey(tab.id),
|
||||
endActionPane: ActionPane(
|
||||
motion: const DrawerMotion(),
|
||||
extentRatio: 0.6,
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => onTabEdited(tab),
|
||||
icon: Icons.edit_outlined,
|
||||
label: l10n.edit,
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (_) => onTabDeleted(tab),
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: l10n.delete,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.14)
|
||||
: scheme.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.6)
|
||||
: scheme.onSurface.withValues(alpha: 0.06),
|
||||
width: selected ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => onTabSelected(tab.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
tab.customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.tabItemSummary(
|
||||
tab.itemCount,
|
||||
tab.totalInCents.asEuro,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return _TabRow(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Slidable(
|
||||
key: ValueKey(tab.id),
|
||||
endActionPane: ActionPane(
|
||||
motion: const DrawerMotion(),
|
||||
extentRatio: 0.6,
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => onTabEdited(tab),
|
||||
icon: Icons.edit_outlined,
|
||||
label: l10n.edit,
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (_) => onTabDeleted(tab),
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: l10n.delete,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _TabCard(
|
||||
tab: tab,
|
||||
selected: tab.id == selectedTabId,
|
||||
onTap: () => onTabSelected(tab.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -275,3 +316,196 @@ class OpenTabsList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabRow extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TabRow({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 8), child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabCard extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _TabCard({required this.tab, this.selected = false, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.14)
|
||||
: scheme.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.6)
|
||||
: scheme.onSurface.withValues(alpha: 0.06),
|
||||
width: selected ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
tab.customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.tabItemSummary(tab.itemCount, tab.totalInCents.asEuro),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LetterIndexBar extends StatelessWidget {
|
||||
final Set<String> availableLetters;
|
||||
final String? activeLetter;
|
||||
final bool isTouched;
|
||||
final ValueChanged<String> onLetterSelected;
|
||||
final VoidCallback onReleased;
|
||||
|
||||
const _LetterIndexBar({
|
||||
required this.availableLetters,
|
||||
required this.activeLetter,
|
||||
required this.isTouched,
|
||||
required this.onLetterSelected,
|
||||
required this.onReleased,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final letterHeight = constraints.maxHeight / indexLetters.length;
|
||||
final fontSize = min(13.0, letterHeight * 0.65);
|
||||
|
||||
String letterAt(Offset position) {
|
||||
final index = (position.dy / letterHeight).floor();
|
||||
return indexLetters[index.clamp(0, indexLetters.length - 1)];
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) =>
|
||||
onLetterSelected(letterAt(details.localPosition)),
|
||||
onTapUp: (_) => onReleased(),
|
||||
onTapCancel: onReleased,
|
||||
onVerticalDragStart: (details) =>
|
||||
onLetterSelected(letterAt(details.localPosition)),
|
||||
onVerticalDragUpdate: (details) =>
|
||||
onLetterSelected(letterAt(details.localPosition)),
|
||||
onVerticalDragEnd: (_) => onReleased(),
|
||||
onVerticalDragCancel: onReleased,
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final letter in indexLetters)
|
||||
SizedBox(
|
||||
height: letterHeight,
|
||||
child: Center(
|
||||
child: _IndexLetter(
|
||||
letter: letter,
|
||||
fontSize: fontSize,
|
||||
isActive: letter == activeLetter,
|
||||
isTouched: isTouched,
|
||||
hasNames: availableLetters.contains(letter),
|
||||
scheme: scheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndexLetter extends StatelessWidget {
|
||||
final String letter;
|
||||
final double fontSize;
|
||||
final bool isActive;
|
||||
final bool isTouched;
|
||||
final bool hasNames;
|
||||
final ColorScheme scheme;
|
||||
|
||||
const _IndexLetter({
|
||||
required this.letter,
|
||||
required this.fontSize,
|
||||
required this.isActive,
|
||||
required this.isTouched,
|
||||
required this.hasNames,
|
||||
required this.scheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch ((isActive, isTouched, hasNames)) {
|
||||
(true, true, _) => scheme.onPrimary,
|
||||
(true, false, _) => scheme.primary,
|
||||
(false, _, true) => scheme.onSurface,
|
||||
(false, _, false) => scheme.onSurface.withValues(alpha: 0.25),
|
||||
};
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: fontSize * 1.8,
|
||||
height: fontSize * 1.8,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive && isTouched ? scheme.primary : Colors.transparent,
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
height: 1,
|
||||
fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
|
||||
Future<void> showNewTabDialog(BuildContext context) async {
|
||||
/// Returns the name of the opened tab, or null when nothing was opened.
|
||||
Future<String?> showNewTabDialog(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final controller = TextEditingController();
|
||||
|
||||
@@ -38,16 +39,18 @@ Future<void> showNewTabDialog(BuildContext context) async {
|
||||
},
|
||||
);
|
||||
|
||||
if (name == null || name.trim().isEmpty) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
final trimmed = name?.trim() ?? '';
|
||||
if (trimmed.isEmpty || !context.mounted) return null;
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().createTab(name.trim());
|
||||
await context.read<BarScreenViewModel>().createTab(trimmed);
|
||||
return trimmed;
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
Product? _existingProduct;
|
||||
String? _imagePath;
|
||||
bool _hasImage = false;
|
||||
bool _infiniteStock = false;
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@@ -79,6 +80,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
_priceController.text = (product.priceInCents / 100).toStringAsFixed(2);
|
||||
_stockController.text = product.stockQuantity.toString();
|
||||
_lowStockController.text = product.lowStockThreshold.toString();
|
||||
_infiniteStock = product.infiniteStock;
|
||||
_imagePath = product.imagePath;
|
||||
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
|
||||
|
||||
@@ -189,6 +191,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
priceInCents: priceInCents,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: _infiniteStock,
|
||||
imagePath: _imagePath,
|
||||
);
|
||||
|
||||
@@ -200,6 +203,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
priceInCents: priceInCents,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: _infiniteStock,
|
||||
imagePath: _imagePath,
|
||||
);
|
||||
}
|
||||
@@ -429,46 +433,64 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _stockController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.stock,
|
||||
border: OutlineInputBorder(),
|
||||
SwitchListTile(
|
||||
value: _infiniteStock,
|
||||
onChanged: (value) {
|
||||
setState(() => _infiniteStock = value);
|
||||
},
|
||||
title: Text(l10n.infiniteStock),
|
||||
subtitle: Text(l10n.infiniteStockDescription),
|
||||
secondary: const Icon(Icons.all_inclusive_rounded),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
final number = int.tryParse(value ?? '');
|
||||
|
||||
if (number == null || number < 0) {
|
||||
return l10n.enterValidStock;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _lowStockController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.lowStockThreshold,
|
||||
border: OutlineInputBorder(),
|
||||
if (!_infiniteStock) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _stockController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.stock,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
final number = int.tryParse(value ?? '');
|
||||
|
||||
if (number == null || number < 0) {
|
||||
return l10n.enterValidStock;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
validator: (value) {
|
||||
final number = int.tryParse(value ?? '');
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _lowStockController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.lowStockThreshold,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
validator: (value) {
|
||||
final number = int.tryParse(value ?? '');
|
||||
|
||||
if (number == null || number < 0) {
|
||||
return l10n.enterValidThreshold;
|
||||
}
|
||||
if (number == null || number < 0) {
|
||||
return l10n.enterValidThreshold;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
|
||||
@@ -90,11 +90,16 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
),
|
||||
title: Text(product.name),
|
||||
subtitle: Text(
|
||||
l10n.productStockSummary(
|
||||
l10n.categoryLabel(product.category),
|
||||
product.priceInCents.asEuro,
|
||||
product.stockQuantity,
|
||||
),
|
||||
product.infiniteStock
|
||||
? l10n.productInfiniteStockSummary(
|
||||
l10n.categoryLabel(product.category),
|
||||
product.priceInCents.asEuro,
|
||||
)
|
||||
: l10n.productStockSummary(
|
||||
l10n.categoryLabel(product.category),
|
||||
product.priceInCents.asEuro,
|
||||
product.stockQuantity,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/products/${product.id}/edit'),
|
||||
|
||||
+388
-226
@@ -32,112 +32,74 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
|
||||
update: widget.update,
|
||||
simulate: widget.simulate,
|
||||
simulateError: widget.simulateError,
|
||||
);
|
||||
_vm.addListener(_onVmChange);
|
||||
_vm.start();
|
||||
}
|
||||
|
||||
void _onVmChange() {
|
||||
if (!mounted) return;
|
||||
|
||||
if (_vm.phase == OtaPhase.done) {
|
||||
_showRestartDialog();
|
||||
} else if (_vm.phase == OtaPhase.error) {
|
||||
_showErrorSnackBar();
|
||||
}
|
||||
}
|
||||
|
||||
void _showRestartDialog() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.updateReady),
|
||||
content: Text(l10n.updateReadyDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
this.context.popOrGo('/bar');
|
||||
},
|
||||
child: Text(l10n.later),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(l10n.restartApp),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showErrorSnackBar() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.updateError(_vm.errorMessage)),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
action: SnackBarAction(
|
||||
label: l10n.retry,
|
||||
onPressed: () {
|
||||
_vm.cancel();
|
||||
_vm.start();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
)..init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vm.removeListener(_onVmChange);
|
||||
_vm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _close() {
|
||||
if (_vm.isBusy) {
|
||||
_showCancelDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
_vm.cancel();
|
||||
context.popOrGo('/bar');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) _showCancelDialog();
|
||||
if (!didPop) _close();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _showCancelDialog(),
|
||||
),
|
||||
title: Text(l10n.updating),
|
||||
centerTitle: true,
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: _close),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _vm,
|
||||
builder: (context, child) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 40),
|
||||
_buildProgress(_vm),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatus(_vm),
|
||||
const Spacer(),
|
||||
_buildVersionInfo(_vm),
|
||||
],
|
||||
return SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(vm: _vm),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: switch (_vm.phase) {
|
||||
UpdatePhase.downloading ||
|
||||
UpdatePhase.installing => _Progress(vm: _vm),
|
||||
UpdatePhase.waitingForInstaller =>
|
||||
_WaitingForInstaller(onRetry: _vm.start),
|
||||
UpdatePhase.failed => _Failed(
|
||||
vm: _vm,
|
||||
onClose: _close,
|
||||
),
|
||||
},
|
||||
),
|
||||
if (widget.update.notes.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 32),
|
||||
_ReleaseNotes(notes: widget.update.notes.trim()),
|
||||
],
|
||||
if (_vm.isBusy) ...[
|
||||
const SizedBox(height: 24),
|
||||
const _DoNotCloseHint(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -148,163 +110,27 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.system_update_alt_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('KoolTap', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.version(widget.update.version),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgress(UpdateProgressViewModel vm) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox.expand(
|
||||
child: CircularProgressIndicator(
|
||||
value: vm.phase == OtaPhase.installing ? null : vm.progress,
|
||||
strokeWidth: 10,
|
||||
backgroundColor: scheme.primary.withValues(alpha: 0.12),
|
||||
color: vm.phase == OtaPhase.error
|
||||
? scheme.error
|
||||
: scheme.primary,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (vm.phase == OtaPhase.error)
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: scheme.error,
|
||||
)
|
||||
else if (vm.phase == OtaPhase.installing)
|
||||
Icon(
|
||||
Icons.settings_rounded,
|
||||
size: 48,
|
||||
color: scheme.primary,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${(vm.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vm.phase == OtaPhase.installing ? l10n.installing : '',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (vm.phase == OtaPhase.downloading)
|
||||
LinearProgressIndicator(
|
||||
value: vm.progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatus(UpdateProgressViewModel vm) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final color = switch (vm.phase) {
|
||||
OtaPhase.error => scheme.error,
|
||||
OtaPhase.done => Colors.green,
|
||||
_ => scheme.onSurface,
|
||||
};
|
||||
|
||||
return Text(
|
||||
l10n.updateStatus(vm.statusText),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVersionInfo(UpdateProgressViewModel vm) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.doNotCloseDuringUpdate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showCancelDialog() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.cancelUpdate),
|
||||
content: Text(l10n.cancelUpdateDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(l10n.continueUpdate),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
_vm.cancel();
|
||||
this.context.popOrGo('/bar');
|
||||
context.popOrGo('/bar');
|
||||
},
|
||||
child: Text(l10n.cancelUpdateAction),
|
||||
),
|
||||
@@ -313,3 +139,339 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final UpdateProgressViewModel vm;
|
||||
|
||||
const _Header({required this.vm});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final (icon, background, foreground) = switch (vm.phase) {
|
||||
UpdatePhase.downloading => (
|
||||
Icons.download_rounded,
|
||||
scheme.primaryContainer,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
UpdatePhase.installing => (
|
||||
Icons.install_mobile_rounded,
|
||||
scheme.primaryContainer,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
UpdatePhase.waitingForInstaller => (
|
||||
Icons.touch_app_rounded,
|
||||
scheme.tertiaryContainer,
|
||||
scheme.onTertiaryContainer,
|
||||
),
|
||||
UpdatePhase.failed => (
|
||||
Icons.error_outline_rounded,
|
||||
scheme.errorContainer,
|
||||
scheme.onErrorContainer,
|
||||
),
|
||||
};
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Icon(icon, key: ValueKey(icon), size: 44, color: foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.updatingTo(vm.update.version),
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (vm.currentVersion case final current?) ...[
|
||||
const SizedBox(height: 12),
|
||||
_VersionChange(from: current, to: vm.update.version),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionChange extends StatelessWidget {
|
||||
final String from;
|
||||
final String to;
|
||||
|
||||
const _VersionChange({required this.from, required this.to});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final style = theme.textTheme.labelLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(from, style: style?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(to, style: style?.copyWith(color: scheme.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Progress extends StatelessWidget {
|
||||
final UpdateProgressViewModel vm;
|
||||
|
||||
const _Progress({required this.vm});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final installing = vm.phase == UpdatePhase.installing;
|
||||
final progress = vm.progress;
|
||||
|
||||
final status = switch ((installing, progress)) {
|
||||
(true, _) => l10n.installingUpdate,
|
||||
(false, null) => l10n.preparingDownload,
|
||||
(false, _) => l10n.downloadingUpdate,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
status,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!installing)
|
||||
Text(
|
||||
'${((progress ?? 0) * 100).round()}%',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: progress ?? 0),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, value, child) => LinearProgressIndicator(
|
||||
value: installing || progress == null ? null : value,
|
||||
minHeight: 12,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
backgroundColor: scheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WaitingForInstaller extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _WaitingForInstaller({required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return _Panel(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
foreground: theme.colorScheme.onTertiaryContainer,
|
||||
title: l10n.confirmInstallation,
|
||||
message: l10n.confirmInstallationDescription,
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(l10n.openInstallerAgain),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Failed extends StatelessWidget {
|
||||
final UpdateProgressViewModel vm;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const _Failed({required this.vm, required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return _Panel(
|
||||
color: scheme.errorContainer,
|
||||
foreground: scheme.onErrorContainer,
|
||||
title: l10n.updateFailed,
|
||||
message: l10n.updateFailureText(vm.failure),
|
||||
actions: [
|
||||
TextButton(onPressed: onClose, child: Text(l10n.close)),
|
||||
FilledButton.icon(
|
||||
onPressed: vm.start,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Panel extends StatelessWidget {
|
||||
final Color color;
|
||||
final Color foreground;
|
||||
final String title;
|
||||
final String message;
|
||||
final List<Widget> actions;
|
||||
|
||||
const _Panel({
|
||||
required this.color,
|
||||
required this.foreground,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: actions,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReleaseNotes extends StatelessWidget {
|
||||
final String notes;
|
||||
|
||||
const _ReleaseNotes({required this.notes});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.whatsNew,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(notes, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DoNotCloseHint extends StatelessWidget {
|
||||
const _DoNotCloseHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.doNotCloseDuringUpdate,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,8 @@ class ProductTile extends StatelessWidget {
|
||||
final scheme = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final outOfStock = product.stockQuantity <= 0;
|
||||
final lowStock =
|
||||
product.stockQuantity > 0 &&
|
||||
product.stockQuantity <= product.lowStockThreshold;
|
||||
final outOfStock = product.isOutOfStock;
|
||||
final lowStock = !outOfStock && product.isLowStock;
|
||||
|
||||
final hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
|
||||
|
||||
@@ -142,11 +140,11 @@ class _StockBadge extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (product.stockQuantity <= 0) {
|
||||
if (product.isOutOfStock) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final low = product.stockQuantity <= product.lowStockThreshold;
|
||||
final low = product.isLowStock;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
@@ -157,7 +155,7 @@ class _StockBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'${product.stockQuantity}',
|
||||
product.infiniteStock ? '∞' : '${product.stockQuantity}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
||||
+31
-15
@@ -7,7 +7,8 @@ $ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
$updateServer = 'https://updater.kooltap.com'
|
||||
$apkPath = 'build\app\outputs\flutter-apk\app-release.apk'
|
||||
$pubspecPath = Join-Path $PSScriptRoot 'pubspec.yaml'
|
||||
$apkPath = Join-Path $PSScriptRoot 'build\app\outputs\flutter-apk\app-release.apk'
|
||||
|
||||
function Fail([string]$message) {
|
||||
Write-Host $message -ForegroundColor Red
|
||||
@@ -16,7 +17,7 @@ function Fail([string]$message) {
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$pubspec = [IO.File]::ReadAllText('pubspec.yaml')
|
||||
$pubspec = [IO.File]::ReadAllText($pubspecPath)
|
||||
$match = [regex]::Match(
|
||||
$pubspec,
|
||||
'(?m)^version:[ \t]*([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)'
|
||||
@@ -29,22 +30,24 @@ $currentName = $match.Groups[1].Value
|
||||
$currentBuild = [int]$match.Groups[2].Value
|
||||
Write-Host "Current version: $currentName+$currentBuild"
|
||||
|
||||
# The server picks the latest release by build number, so every release needs
|
||||
# a higher one than anything already published
|
||||
# The server picks the latest release by Android version code, so every release
|
||||
# needs a higher one than anything already published
|
||||
$publishedName = $null
|
||||
$publishedBuild = 0
|
||||
$publishedCode = 0
|
||||
try {
|
||||
foreach ($release in Invoke-RestMethod "$updateServer/api/releases") {
|
||||
if ($release.VersionCode -gt $publishedBuild) {
|
||||
$releases = Invoke-RestMethod "$updateServer/api/releases" -UseBasicParsing -TimeoutSec 15
|
||||
foreach ($release in $releases) {
|
||||
if ($release.VersionCode -gt $publishedCode) {
|
||||
$publishedName = $release.Version
|
||||
$publishedBuild = [int]$release.VersionCode
|
||||
$publishedCode = [int]$release.VersionCode
|
||||
}
|
||||
}
|
||||
if ($publishedName) {
|
||||
Write-Host "Latest published: $publishedName+$publishedBuild"
|
||||
Write-Host "Latest published: $publishedName (version code $publishedCode)"
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Could not reach $updateServer, using pubspec.yaml only." -ForegroundColor Yellow
|
||||
Write-Host "Could not reach ${updateServer}: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host 'Using pubspec.yaml only; the upload is refused if the version is too low.' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$name = Read-Host 'Release version (for example 1.2.2)'
|
||||
@@ -55,14 +58,27 @@ if ($publishedName -and [version]$name -le [version]$publishedName) {
|
||||
Fail "$name must be higher than the published $publishedName, or tablets will not see it."
|
||||
}
|
||||
|
||||
$build = [Math]::Max($currentBuild, $publishedBuild) + 1
|
||||
# The build number only counts builds of the same version and starts at 0 for
|
||||
# a new one. Android refuses updates with a lower version code, so that code is
|
||||
# derived from the whole version instead: 1.2.5+3 becomes 1020503
|
||||
$build = if ($name -eq $currentName) { $currentBuild + 1 } else { 0 }
|
||||
$major, $minor, $patch = $name.Split('.') | ForEach-Object { [int]$_ }
|
||||
if ($minor -ge 100 -or $patch -ge 100 -or $build -ge 100) {
|
||||
Fail 'Minor, patch and build numbers must stay below 100.'
|
||||
}
|
||||
|
||||
$versionCode = $major * 1000000 + $minor * 10000 + $patch * 100 + $build
|
||||
if ($versionCode -le $publishedCode) {
|
||||
Fail "Version code $versionCode is not higher than the published $publishedCode."
|
||||
}
|
||||
|
||||
$version = "$name+$build"
|
||||
|
||||
$versionStart = $match.Groups[1].Index
|
||||
$versionEnd = $match.Groups[2].Index + $match.Groups[2].Length
|
||||
$pubspec = $pubspec.Substring(0, $versionStart) + $version + $pubspec.Substring($versionEnd)
|
||||
[IO.File]::WriteAllText((Resolve-Path 'pubspec.yaml'), $pubspec)
|
||||
Write-Host "Updated pubspec.yaml to version $version."
|
||||
[IO.File]::WriteAllText($pubspecPath, $pubspec)
|
||||
Write-Host "Updated pubspec.yaml to version $version (version code $versionCode)."
|
||||
|
||||
if (-not $SkipTests) {
|
||||
Write-Host 'Running tests...'
|
||||
@@ -71,7 +87,7 @@ if (-not $SkipTests) {
|
||||
}
|
||||
|
||||
Write-Host 'Building release APK...'
|
||||
flutter build apk --release
|
||||
flutter build apk --release --build-number=$versionCode
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'Release APK build failed.' }
|
||||
Write-Host "Release APK created at $apkPath"
|
||||
|
||||
@@ -93,7 +109,7 @@ $client = [Net.Http.HttpClient]::new()
|
||||
$client.Timeout = [TimeSpan]::FromMinutes(5)
|
||||
$client.DefaultRequestHeaders.Add('X-Admin-Token', $token)
|
||||
|
||||
$apk = [IO.File]::OpenRead((Resolve-Path $apkPath))
|
||||
$apk = [IO.File]::OpenRead($apkPath)
|
||||
$form = [Net.Http.MultipartFormDataContent]::new()
|
||||
$form.Add([Net.Http.StreamContent]::new($apk), 'apk', 'app-release.apk')
|
||||
$form.Add([Net.Http.StringContent]::new($notes), 'notes')
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: kooltap
|
||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.1+1
|
||||
version: 1.2.7+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/database/app_database.dart';
|
||||
|
||||
/// The parts of the v12 schema that the v13 migration touches.
|
||||
/// The parts of the v12 schema that the v13 and v14 migrations touch.
|
||||
const _v12Schema = [
|
||||
'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, '
|
||||
"status TEXT NOT NULL DEFAULT 'open', opened_at INTEGER NOT NULL, "
|
||||
@@ -15,6 +15,13 @@ const _v12Schema = [
|
||||
'CREATE TABLE tab_item_purchases (id TEXT NOT NULL, '
|
||||
'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, '
|
||||
'removed_at INTEGER NULL, PRIMARY KEY (id))',
|
||||
'CREATE TABLE products (id TEXT NOT NULL, name TEXT NOT NULL, '
|
||||
'category TEXT NOT NULL, stock_quantity INTEGER NOT NULL, '
|
||||
'low_stock_threshold INTEGER NOT NULL, price_in_cents INTEGER NOT NULL, '
|
||||
'image_path TEXT NULL, active INTEGER NOT NULL DEFAULT 1, '
|
||||
'PRIMARY KEY (id))',
|
||||
"INSERT INTO products VALUES ('prod-1', 'Beer', 'Drinks', 20, 5, 400, "
|
||||
'NULL, 1)',
|
||||
"INSERT INTO bar_tabs VALUES ('tab-1', 'Alice', 'open', 1700000000, NULL, 4)",
|
||||
"INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, "
|
||||
'1700000100)',
|
||||
@@ -68,6 +75,13 @@ void main() {
|
||||
expect(purchases.where((row) => row.removedAt != null), hasLength(1));
|
||||
});
|
||||
|
||||
test('gives existing products a counted stock', () async {
|
||||
final product = await database.select(database.products).getSingle();
|
||||
|
||||
expect(product.infiniteStock, false);
|
||||
expect(product.stockQuantity, 20);
|
||||
});
|
||||
|
||||
test('deletes purchases whose item no longer exists', () async {
|
||||
final orphan = await (database.select(
|
||||
database.tabItemPurchases,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/l10n/app_localizations.dart';
|
||||
import 'package:kooltap/models/product.dart';
|
||||
import 'package:kooltap/views/bar/product_grid.dart';
|
||||
import 'package:kooltap/views/widgets/product_tile.dart';
|
||||
|
||||
final _products = [
|
||||
for (var i = 0; i < 60; i++)
|
||||
Product(
|
||||
id: 'prod-$i',
|
||||
name: 'Product $i',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 10,
|
||||
lowStockThreshold: 2,
|
||||
priceInCents: 300,
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
Future<void> pumpGrid(WidgetTester tester, int rows, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ProductGrid(
|
||||
products: _products,
|
||||
rows: rows,
|
||||
hasSelectedTab: true,
|
||||
onProductTap: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final size in [const Size(853, 720), const Size(1280, 1000)]) {
|
||||
for (var rows = 2; rows <= 6; rows++) {
|
||||
testWidgets('shows exactly $rows rows at $size', (tester) async {
|
||||
await pumpGrid(tester, rows, size);
|
||||
|
||||
final grid = tester.getRect(find.byType(GridView));
|
||||
final tiles = tester
|
||||
.widgetList(find.byType(ProductTile))
|
||||
.map((tile) => tester.getRect(find.byWidget(tile)))
|
||||
.toList();
|
||||
|
||||
final fullyVisibleRows = {
|
||||
for (final tile in tiles)
|
||||
if (tile.top >= grid.top && tile.bottom <= grid.bottom + 0.5)
|
||||
tile.top.round(),
|
||||
};
|
||||
expect(fullyVisibleRows, hasLength(rows));
|
||||
|
||||
final lastRowBottom = tiles
|
||||
.where((tile) => tile.bottom <= grid.bottom + 0.5)
|
||||
.map((tile) => tile.bottom)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
expect(
|
||||
grid.bottom - lastRowBottom,
|
||||
closeTo(16, 1),
|
||||
reason: 'the rows fill the height down to the bottom padding',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,5 +319,64 @@ void main() {
|
||||
expect(product!.stockQuantity, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('infinite stock', () {
|
||||
setUp(() async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: 'water',
|
||||
name: 'Water',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: 0,
|
||||
infiniteStock: const Value(true),
|
||||
priceInCents: 100,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sells without stock and leaves the count alone', () async {
|
||||
await service.decreaseStock('water', 5);
|
||||
await service.increaseStock('water', 2);
|
||||
|
||||
final product = await service.getProductById('water');
|
||||
expect(product!.stockQuantity, 0);
|
||||
expect(product.infiniteStock, true);
|
||||
expect(product.isOutOfStock, false);
|
||||
expect(product.isLowStock, false);
|
||||
});
|
||||
|
||||
test('still reports a missing product', () async {
|
||||
await expectLater(
|
||||
service.increaseStock('nonexistent', 1),
|
||||
throwsA(isA<ProductNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('is saved by create and update', () async {
|
||||
await service.createProduct(
|
||||
name: 'Tap water',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: 0,
|
||||
infiniteStock: true,
|
||||
priceInCents: 0,
|
||||
imagePath: null,
|
||||
);
|
||||
final created = (await service.getProducts()).firstWhere(
|
||||
(product) => product.name == 'Tap water',
|
||||
);
|
||||
expect(created.infiniteStock, true);
|
||||
|
||||
await service.updateProduct(
|
||||
created.copyWith(infiniteStock: false, stockQuantity: 4),
|
||||
);
|
||||
final updated = await service.getProductById(created.id);
|
||||
expect(updated!.infiniteStock, false);
|
||||
expect(updated.stockQuantity, 4);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/l10n/app_localizations.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/utils/name_index.dart';
|
||||
import 'package:kooltap/views/bar/tab_panel.dart';
|
||||
|
||||
const _names = [
|
||||
'Zoë', 'anna', 'Émile', 'Bram', 'Mieke', 'Marc', 'Ward', 'Tom', 'Sofie', //
|
||||
'Lotte', 'Koen', 'Jan', 'Inge', 'Hanne', 'Greet', 'Frank', 'Dirk', 'Chris',
|
||||
'Nele', 'Olaf', 'Pieter', 'Rik', 'Vera', 'Yves', '2de tafel', 'Eva',
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('name index', () {
|
||||
test('folds case and accents into the index letter', () {
|
||||
expect(indexLetterOf('émile'), 'E');
|
||||
expect(indexLetterOf('Zoë'), 'Z');
|
||||
expect(indexLetterOf(' anna'), 'A');
|
||||
expect(indexLetterOf('2de tafel'), '#');
|
||||
expect(indexLetterOf(''), '#');
|
||||
});
|
||||
|
||||
test('sorts A–Z regardless of case and accents, # last', () {
|
||||
final sorted = ['Zoë', 'émile', 'Eva', 'anna', '2de tafel', 'Bram']
|
||||
..sort(compareByIndex);
|
||||
|
||||
expect(sorted, ['anna', 'Bram', 'émile', 'Eva', 'Zoë', '2de tafel']);
|
||||
});
|
||||
});
|
||||
|
||||
group('TabPanel letter index', () {
|
||||
final tabs = [
|
||||
for (final (index, name) in _names.indexed)
|
||||
BarTab(
|
||||
id: 'tab-$index',
|
||||
customerName: name,
|
||||
openedAt: DateTime(2026, 10, 11, 12, index),
|
||||
items: const [],
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> pumpPanel(
|
||||
WidgetTester tester, {
|
||||
bool showSearch = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: TabPanel(
|
||||
tabs: tabs,
|
||||
selectedTab: null,
|
||||
selectedTabId: null,
|
||||
stockByProductId: const {},
|
||||
showSearch: showSearch,
|
||||
onTabSelected: (_) {},
|
||||
onItemQuantityChanged: (_, _) async {},
|
||||
onCloseTabPressed: () {},
|
||||
onTabEdited: (_) async {},
|
||||
onTabDeleted: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String firstVisibleName(WidgetTester tester) {
|
||||
final list = tester.getRect(find.byType(OpenTabsList));
|
||||
final visible = _names.where((name) {
|
||||
final finder = find.text(name);
|
||||
if (finder.evaluate().isEmpty) return false;
|
||||
final rect = tester.getRect(finder);
|
||||
return rect.top >= list.top && rect.bottom <= list.bottom;
|
||||
}).toList();
|
||||
|
||||
return visible.reduce(
|
||||
(a, b) =>
|
||||
tester.getRect(find.text(a)).top < tester.getRect(find.text(b)).top
|
||||
? a
|
||||
: b,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> tapLetter(WidgetTester tester, String letter) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) => widget.runtimeType.toString() == '_LetterIndexBar',
|
||||
),
|
||||
matching: find.text(letter),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('lists the tabs alphabetically', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
|
||||
expect(firstVisibleName(tester), 'anna');
|
||||
});
|
||||
|
||||
testWidgets('tapping a letter scrolls to its first name', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
|
||||
await tapLetter(tester, 'M');
|
||||
expect(firstVisibleName(tester), 'Marc');
|
||||
|
||||
await tapLetter(tester, 'E');
|
||||
expect(firstVisibleName(tester), 'Émile');
|
||||
});
|
||||
|
||||
testWidgets('shows the search bar only when asked', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
await pumpPanel(tester, showSearch: true);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('closing the search clears the filter', (tester) async {
|
||||
await pumpPanel(tester, showSearch: true);
|
||||
await tester.enterText(find.byType(TextField), 'Marc');
|
||||
await tester.pump();
|
||||
expect(find.text('anna'), findsNothing);
|
||||
|
||||
await pumpPanel(tester);
|
||||
expect(find.text('anna'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a letter without names goes to the next one', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
|
||||
await tapLetter(tester, 'Q');
|
||||
expect(firstVisibleName(tester), 'Rik');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user