feat: alphabet list, change Layout etc

This commit is contained in:
2026-10-11 20:37:59 +02:00
parent 489896305d
commit 072f560989
31 changed files with 1847 additions and 832 deletions
+9 -1
View File
@@ -103,6 +103,10 @@ GoRouter createAppRouter({
required AdminPinViewModel adminPinViewModel, required AdminPinViewModel adminPinViewModel,
required RemoteSyncViewModel remoteSyncViewModel, 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( return GoRouter(
initialLocation: '/bar', initialLocation: '/bar',
observers: [routeObserver, SentryNavigatorObserver()], observers: [routeObserver, SentryNavigatorObserver()],
@@ -194,8 +198,12 @@ GoRouter createAppRouter({
GoRoute( GoRoute(
path: '/update-progress', path: '/update-progress',
redirect: (context, state) {
if (state.extra case final UpdateInfo update) lastUpdate = update;
return lastUpdate == null ? '/settings' : null;
},
builder: (context, state) { builder: (context, state) {
final update = state.extra as UpdateInfo; final update = lastUpdate!;
final simulate = state.uri.queryParameters['simulate'] == 'true'; final simulate = state.uri.queryParameters['simulate'] == 'true';
final simulateError = final simulateError =
state.uri.queryParameters['simulateError'] == 'true'; state.uri.queryParameters['simulateError'] == 'true';
+9 -1
View File
@@ -30,6 +30,10 @@ class Products extends Table {
IntColumn get lowStockThreshold => integer()(); 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()(); IntColumn get priceInCents => integer()();
TextColumn get imagePath => text().nullable()(); TextColumn get imagePath => text().nullable()();
@@ -200,7 +204,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 13; int get schemaVersion => 14;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -295,6 +299,10 @@ class AppDatabase extends _$AppDatabase {
await _backfillPurchaseRows(); await _backfillPurchaseRows();
await _deleteOrphanedPurchaseRows(); await _deleteOrphanedPurchaseRows();
} }
if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) {
await migrator.addColumn(products, products.infiniteStock);
}
}, },
); );
} }
+77
View File
@@ -60,6 +60,21 @@ class $ProductsTable extends Products
type: DriftSqlType.int, type: DriftSqlType.int,
requiredDuringInsert: true, 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( static const VerificationMeta _priceInCentsMeta = const VerificationMeta(
'priceInCents', 'priceInCents',
); );
@@ -102,6 +117,7 @@ class $ProductsTable extends Products
category, category,
stockQuantity, stockQuantity,
lowStockThreshold, lowStockThreshold,
infiniteStock,
priceInCents, priceInCents,
imagePath, imagePath,
active, active,
@@ -161,6 +177,15 @@ class $ProductsTable extends Products
} else if (isInserting) { } else if (isInserting) {
context.missing(_lowStockThresholdMeta); context.missing(_lowStockThresholdMeta);
} }
if (data.containsKey('infinite_stock')) {
context.handle(
_infiniteStockMeta,
infiniteStock.isAcceptableOrUnknown(
data['infinite_stock']!,
_infiniteStockMeta,
),
);
}
if (data.containsKey('price_in_cents')) { if (data.containsKey('price_in_cents')) {
context.handle( context.handle(
_priceInCentsMeta, _priceInCentsMeta,
@@ -213,6 +238,10 @@ class $ProductsTable extends Products
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}low_stock_threshold'], data['${effectivePrefix}low_stock_threshold'],
)!, )!,
infiniteStock: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}infinite_stock'],
)!,
priceInCents: attachedDatabase.typeMapping.read( priceInCents: attachedDatabase.typeMapping.read(
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}price_in_cents'], data['${effectivePrefix}price_in_cents'],
@@ -240,6 +269,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
final String category; final String category;
final int stockQuantity; final int stockQuantity;
final int lowStockThreshold; final int lowStockThreshold;
/// Products like tap water that are never counted; their stock is ignored.
final bool infiniteStock;
final int priceInCents; final int priceInCents;
final String? imagePath; final String? imagePath;
final bool active; final bool active;
@@ -249,6 +281,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
required this.category, required this.category,
required this.stockQuantity, required this.stockQuantity,
required this.lowStockThreshold, required this.lowStockThreshold,
required this.infiniteStock,
required this.priceInCents, required this.priceInCents,
this.imagePath, this.imagePath,
required this.active, required this.active,
@@ -261,6 +294,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
map['category'] = Variable<String>(category); map['category'] = Variable<String>(category);
map['stock_quantity'] = Variable<int>(stockQuantity); map['stock_quantity'] = Variable<int>(stockQuantity);
map['low_stock_threshold'] = Variable<int>(lowStockThreshold); map['low_stock_threshold'] = Variable<int>(lowStockThreshold);
map['infinite_stock'] = Variable<bool>(infiniteStock);
map['price_in_cents'] = Variable<int>(priceInCents); map['price_in_cents'] = Variable<int>(priceInCents);
if (!nullToAbsent || imagePath != null) { if (!nullToAbsent || imagePath != null) {
map['image_path'] = Variable<String>(imagePath); map['image_path'] = Variable<String>(imagePath);
@@ -276,6 +310,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
category: Value(category), category: Value(category),
stockQuantity: Value(stockQuantity), stockQuantity: Value(stockQuantity),
lowStockThreshold: Value(lowStockThreshold), lowStockThreshold: Value(lowStockThreshold),
infiniteStock: Value(infiniteStock),
priceInCents: Value(priceInCents), priceInCents: Value(priceInCents),
imagePath: imagePath == null && nullToAbsent imagePath: imagePath == null && nullToAbsent
? const Value.absent() ? const Value.absent()
@@ -295,6 +330,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
category: serializer.fromJson<String>(json['category']), category: serializer.fromJson<String>(json['category']),
stockQuantity: serializer.fromJson<int>(json['stockQuantity']), stockQuantity: serializer.fromJson<int>(json['stockQuantity']),
lowStockThreshold: serializer.fromJson<int>(json['lowStockThreshold']), lowStockThreshold: serializer.fromJson<int>(json['lowStockThreshold']),
infiniteStock: serializer.fromJson<bool>(json['infiniteStock']),
priceInCents: serializer.fromJson<int>(json['priceInCents']), priceInCents: serializer.fromJson<int>(json['priceInCents']),
imagePath: serializer.fromJson<String?>(json['imagePath']), imagePath: serializer.fromJson<String?>(json['imagePath']),
active: serializer.fromJson<bool>(json['active']), active: serializer.fromJson<bool>(json['active']),
@@ -309,6 +345,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
'category': serializer.toJson<String>(category), 'category': serializer.toJson<String>(category),
'stockQuantity': serializer.toJson<int>(stockQuantity), 'stockQuantity': serializer.toJson<int>(stockQuantity),
'lowStockThreshold': serializer.toJson<int>(lowStockThreshold), 'lowStockThreshold': serializer.toJson<int>(lowStockThreshold),
'infiniteStock': serializer.toJson<bool>(infiniteStock),
'priceInCents': serializer.toJson<int>(priceInCents), 'priceInCents': serializer.toJson<int>(priceInCents),
'imagePath': serializer.toJson<String?>(imagePath), 'imagePath': serializer.toJson<String?>(imagePath),
'active': serializer.toJson<bool>(active), 'active': serializer.toJson<bool>(active),
@@ -321,6 +358,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
String? category, String? category,
int? stockQuantity, int? stockQuantity,
int? lowStockThreshold, int? lowStockThreshold,
bool? infiniteStock,
int? priceInCents, int? priceInCents,
Value<String?> imagePath = const Value.absent(), Value<String?> imagePath = const Value.absent(),
bool? active, bool? active,
@@ -330,6 +368,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
category: category ?? this.category, category: category ?? this.category,
stockQuantity: stockQuantity ?? this.stockQuantity, stockQuantity: stockQuantity ?? this.stockQuantity,
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
infiniteStock: infiniteStock ?? this.infiniteStock,
priceInCents: priceInCents ?? this.priceInCents, priceInCents: priceInCents ?? this.priceInCents,
imagePath: imagePath.present ? imagePath.value : this.imagePath, imagePath: imagePath.present ? imagePath.value : this.imagePath,
active: active ?? this.active, active: active ?? this.active,
@@ -345,6 +384,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
lowStockThreshold: data.lowStockThreshold.present lowStockThreshold: data.lowStockThreshold.present
? data.lowStockThreshold.value ? data.lowStockThreshold.value
: this.lowStockThreshold, : this.lowStockThreshold,
infiniteStock: data.infiniteStock.present
? data.infiniteStock.value
: this.infiniteStock,
priceInCents: data.priceInCents.present priceInCents: data.priceInCents.present
? data.priceInCents.value ? data.priceInCents.value
: this.priceInCents, : this.priceInCents,
@@ -361,6 +403,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
..write('category: $category, ') ..write('category: $category, ')
..write('stockQuantity: $stockQuantity, ') ..write('stockQuantity: $stockQuantity, ')
..write('lowStockThreshold: $lowStockThreshold, ') ..write('lowStockThreshold: $lowStockThreshold, ')
..write('infiniteStock: $infiniteStock, ')
..write('priceInCents: $priceInCents, ') ..write('priceInCents: $priceInCents, ')
..write('imagePath: $imagePath, ') ..write('imagePath: $imagePath, ')
..write('active: $active') ..write('active: $active')
@@ -375,6 +418,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
category, category,
stockQuantity, stockQuantity,
lowStockThreshold, lowStockThreshold,
infiniteStock,
priceInCents, priceInCents,
imagePath, imagePath,
active, active,
@@ -388,6 +432,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
other.category == this.category && other.category == this.category &&
other.stockQuantity == this.stockQuantity && other.stockQuantity == this.stockQuantity &&
other.lowStockThreshold == this.lowStockThreshold && other.lowStockThreshold == this.lowStockThreshold &&
other.infiniteStock == this.infiniteStock &&
other.priceInCents == this.priceInCents && other.priceInCents == this.priceInCents &&
other.imagePath == this.imagePath && other.imagePath == this.imagePath &&
other.active == this.active); other.active == this.active);
@@ -399,6 +444,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
final Value<String> category; final Value<String> category;
final Value<int> stockQuantity; final Value<int> stockQuantity;
final Value<int> lowStockThreshold; final Value<int> lowStockThreshold;
final Value<bool> infiniteStock;
final Value<int> priceInCents; final Value<int> priceInCents;
final Value<String?> imagePath; final Value<String?> imagePath;
final Value<bool> active; final Value<bool> active;
@@ -409,6 +455,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
this.category = const Value.absent(), this.category = const Value.absent(),
this.stockQuantity = const Value.absent(), this.stockQuantity = const Value.absent(),
this.lowStockThreshold = const Value.absent(), this.lowStockThreshold = const Value.absent(),
this.infiniteStock = const Value.absent(),
this.priceInCents = const Value.absent(), this.priceInCents = const Value.absent(),
this.imagePath = const Value.absent(), this.imagePath = const Value.absent(),
this.active = const Value.absent(), this.active = const Value.absent(),
@@ -420,6 +467,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
required String category, required String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
this.infiniteStock = const Value.absent(),
required int priceInCents, required int priceInCents,
this.imagePath = const Value.absent(), this.imagePath = const Value.absent(),
this.active = const Value.absent(), this.active = const Value.absent(),
@@ -436,6 +484,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
Expression<String>? category, Expression<String>? category,
Expression<int>? stockQuantity, Expression<int>? stockQuantity,
Expression<int>? lowStockThreshold, Expression<int>? lowStockThreshold,
Expression<bool>? infiniteStock,
Expression<int>? priceInCents, Expression<int>? priceInCents,
Expression<String>? imagePath, Expression<String>? imagePath,
Expression<bool>? active, Expression<bool>? active,
@@ -447,6 +496,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
if (category != null) 'category': category, if (category != null) 'category': category,
if (stockQuantity != null) 'stock_quantity': stockQuantity, if (stockQuantity != null) 'stock_quantity': stockQuantity,
if (lowStockThreshold != null) 'low_stock_threshold': lowStockThreshold, if (lowStockThreshold != null) 'low_stock_threshold': lowStockThreshold,
if (infiniteStock != null) 'infinite_stock': infiniteStock,
if (priceInCents != null) 'price_in_cents': priceInCents, if (priceInCents != null) 'price_in_cents': priceInCents,
if (imagePath != null) 'image_path': imagePath, if (imagePath != null) 'image_path': imagePath,
if (active != null) 'active': active, if (active != null) 'active': active,
@@ -460,6 +510,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
Value<String>? category, Value<String>? category,
Value<int>? stockQuantity, Value<int>? stockQuantity,
Value<int>? lowStockThreshold, Value<int>? lowStockThreshold,
Value<bool>? infiniteStock,
Value<int>? priceInCents, Value<int>? priceInCents,
Value<String?>? imagePath, Value<String?>? imagePath,
Value<bool>? active, Value<bool>? active,
@@ -471,6 +522,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
category: category ?? this.category, category: category ?? this.category,
stockQuantity: stockQuantity ?? this.stockQuantity, stockQuantity: stockQuantity ?? this.stockQuantity,
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
infiniteStock: infiniteStock ?? this.infiniteStock,
priceInCents: priceInCents ?? this.priceInCents, priceInCents: priceInCents ?? this.priceInCents,
imagePath: imagePath ?? this.imagePath, imagePath: imagePath ?? this.imagePath,
active: active ?? this.active, active: active ?? this.active,
@@ -496,6 +548,9 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
if (lowStockThreshold.present) { if (lowStockThreshold.present) {
map['low_stock_threshold'] = Variable<int>(lowStockThreshold.value); map['low_stock_threshold'] = Variable<int>(lowStockThreshold.value);
} }
if (infiniteStock.present) {
map['infinite_stock'] = Variable<bool>(infiniteStock.value);
}
if (priceInCents.present) { if (priceInCents.present) {
map['price_in_cents'] = Variable<int>(priceInCents.value); map['price_in_cents'] = Variable<int>(priceInCents.value);
} }
@@ -519,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
..write('category: $category, ') ..write('category: $category, ')
..write('stockQuantity: $stockQuantity, ') ..write('stockQuantity: $stockQuantity, ')
..write('lowStockThreshold: $lowStockThreshold, ') ..write('lowStockThreshold: $lowStockThreshold, ')
..write('infiniteStock: $infiniteStock, ')
..write('priceInCents: $priceInCents, ') ..write('priceInCents: $priceInCents, ')
..write('imagePath: $imagePath, ') ..write('imagePath: $imagePath, ')
..write('active: $active, ') ..write('active: $active, ')
@@ -3800,6 +3856,7 @@ typedef $$ProductsTableCreateCompanionBuilder =
required String category, required String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
Value<bool> infiniteStock,
required int priceInCents, required int priceInCents,
Value<String?> imagePath, Value<String?> imagePath,
Value<bool> active, Value<bool> active,
@@ -3812,6 +3869,7 @@ typedef $$ProductsTableUpdateCompanionBuilder =
Value<String> category, Value<String> category,
Value<int> stockQuantity, Value<int> stockQuantity,
Value<int> lowStockThreshold, Value<int> lowStockThreshold,
Value<bool> infiniteStock,
Value<int> priceInCents, Value<int> priceInCents,
Value<String?> imagePath, Value<String?> imagePath,
Value<bool> active, Value<bool> active,
@@ -3875,6 +3933,11 @@ class $$ProductsTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<bool> get infiniteStock => $composableBuilder(
column: $table.infiniteStock,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get priceInCents => $composableBuilder( ColumnFilters<int> get priceInCents => $composableBuilder(
column: $table.priceInCents, column: $table.priceInCents,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@@ -3950,6 +4013,11 @@ class $$ProductsTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<bool> get infiniteStock => $composableBuilder(
column: $table.infiniteStock,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get priceInCents => $composableBuilder( ColumnOrderings<int> get priceInCents => $composableBuilder(
column: $table.priceInCents, column: $table.priceInCents,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@@ -3994,6 +4062,11 @@ class $$ProductsTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumn<bool> get infiniteStock => $composableBuilder(
column: $table.infiniteStock,
builder: (column) => column,
);
GeneratedColumn<int> get priceInCents => $composableBuilder( GeneratedColumn<int> get priceInCents => $composableBuilder(
column: $table.priceInCents, column: $table.priceInCents,
builder: (column) => column, builder: (column) => column,
@@ -4064,6 +4137,7 @@ class $$ProductsTableTableManager
Value<String> category = const Value.absent(), Value<String> category = const Value.absent(),
Value<int> stockQuantity = const Value.absent(), Value<int> stockQuantity = const Value.absent(),
Value<int> lowStockThreshold = const Value.absent(), Value<int> lowStockThreshold = const Value.absent(),
Value<bool> infiniteStock = const Value.absent(),
Value<int> priceInCents = const Value.absent(), Value<int> priceInCents = const Value.absent(),
Value<String?> imagePath = const Value.absent(), Value<String?> imagePath = const Value.absent(),
Value<bool> active = const Value.absent(), Value<bool> active = const Value.absent(),
@@ -4074,6 +4148,7 @@ class $$ProductsTableTableManager
category: category, category: category,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: infiniteStock,
priceInCents: priceInCents, priceInCents: priceInCents,
imagePath: imagePath, imagePath: imagePath,
active: active, active: active,
@@ -4086,6 +4161,7 @@ class $$ProductsTableTableManager
required String category, required String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
Value<bool> infiniteStock = const Value.absent(),
required int priceInCents, required int priceInCents,
Value<String?> imagePath = const Value.absent(), Value<String?> imagePath = const Value.absent(),
Value<bool> active = const Value.absent(), Value<bool> active = const Value.absent(),
@@ -4096,6 +4172,7 @@ class $$ProductsTableTableManager
category: category, category: category,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: infiniteStock,
priceInCents: priceInCents, priceInCents: priceInCents,
imagePath: imagePath, imagePath: imagePath,
active: active, active: active,
+23 -15
View File
@@ -21,6 +21,7 @@ class ProductNotFoundException implements Exception {
/// Atomic stock updates shared by the product and tab services, so they can /// Atomic stock updates shared by the product and tab services, so they can
/// run inside either one's transaction. /// run inside either one's transaction.
extension StockQueries on AppDatabase { extension StockQueries on AppDatabase {
/// Products with infinite stock are only checked for existence.
Future<void> decreaseStock(String productId, int amount) async { Future<void> decreaseStock(String productId, int amount) async {
_validateStockAmount(amount); _validateStockAmount(amount);
@@ -28,6 +29,7 @@ extension StockQueries on AppDatabase {
await (update(products)..where( await (update(products)..where(
(row) => (row) =>
row.id.equals(productId) & row.id.equals(productId) &
row.infiniteStock.not() &
row.stockQuantity.isBiggerOrEqualValue(amount), row.stockQuantity.isBiggerOrEqualValue(amount),
)) ))
.write( .write(
@@ -38,28 +40,34 @@ extension StockQueries on AppDatabase {
if (updatedRows == 1) return; if (updatedRows == 1) return;
final product = await (select( final product = await _findProduct(productId);
products, if (!product.infiniteStock) throw const InsufficientStockException();
)..where((row) => row.id.equals(productId))).getSingleOrNull();
if (product == null) throw ProductNotFoundException(productId);
throw const InsufficientStockException();
} }
Future<void> increaseStock(String productId, int amount) async { Future<void> increaseStock(String productId, int amount) async {
_validateStockAmount(amount); _validateStockAmount(amount);
final updatedRows = final updatedRows =
await (update( await (update(products)..where(
products, (row) => row.id.equals(productId) & row.infiniteStock.not(),
)..where((row) => row.id.equals(productId))).write( ))
ProductsCompanion.custom( .write(
stockQuantity: products.stockQuantity + Variable(amount), 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) { void _validateStockAmount(int amount) {
+20 -13
View File
@@ -27,6 +27,11 @@
"@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}}, "@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}},
"searchByName": "Search by name…", "searchByName": "Search by name…",
"clearSearch": "Clear search", "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", "allCustomers": "All customers",
"customer": "Customer", "customer": "Customer",
"noClosedTabs": "No closed tabs yet", "noClosedTabs": "No closed tabs yet",
@@ -102,6 +107,10 @@
"closeTab": "Close tab", "closeTab": "Close tab",
"productStockSummary": "{category} • {price} • Stock: {stock}", "productStockSummary": "{category} • {price} • Stock: {stock}",
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, "@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", "outOfStock": "OUT OF STOCK",
"tabsYouCloseAppearHere": "Tabs you close will show up here.", "tabsYouCloseAppearHere": "Tabs you close will show up here.",
"enterPinSubtitle": "You’ll use this to unlock the app", "enterPinSubtitle": "You’ll use this to unlock the app",
@@ -146,13 +155,8 @@
"@versionAvailable": {"placeholders": {"version": {"type": "String"}}}, "@versionAvailable": {"placeholders": {"version": {"type": "String"}}},
"later": "Later", "later": "Later",
"update": "Update", "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", "updateFailed": "Update failed",
"retry": "Retry", "retry": "Retry",
"updating": "Updating",
"installing": "Installing",
"doNotCloseDuringUpdate": "Do not close the app during the update", "doNotCloseDuringUpdate": "Do not close the app during the update",
"cancelUpdate": "Cancel update?", "cancelUpdate": "Cancel update?",
"cancelUpdateDescription": "The update is in progress. If you leave now, the app may become unstable.", "cancelUpdateDescription": "The update is in progress. If you leave now, the app may become unstable.",
@@ -160,14 +164,19 @@
"cancelUpdateAction": "Cancel update", "cancelUpdateAction": "Cancel update",
"preparingDownload": "Preparing download…", "preparingDownload": "Preparing download…",
"simulatedDownloadError": "Simulated download error (network timeout)", "simulatedDownloadError": "Simulated download error (network timeout)",
"downloadFailed": "Download failed",
"downloading": "Downloading {percent}%",
"@downloading": {"placeholders": {"percent": {"type": "String"}}},
"installingUpdate": "Installing update…", "installingUpdate": "Installing update…",
"updateInstalledRestarting": "Update installed! Restarting…",
"installationFailed": "Installation failed",
"updateAlreadyInProgress": "Update already in progress", "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.", "genericError": "An unexpected error occurred.",
"couldNotLoadBar": "Could not load bar screen.", "couldNotLoadBar": "Could not load bar screen.",
"couldNotCreateTabMessage": "Could not create tab.", "couldNotCreateTabMessage": "Could not create tab.",
@@ -186,8 +195,6 @@
"currentPinIncorrect": "Current PIN is incorrect.", "currentPinIncorrect": "Current PIN is incorrect.",
"couldNotLoadSettings": "Could not load settings.", "couldNotLoadSettings": "Could not load settings.",
"couldNotSaveSettings": "Could not save settings.", "couldNotSaveSettings": "Could not save settings.",
"downloadFailedWithDetail": "Download failed.",
"installationFailedWithDetail": "Installation failed.",
"devMenu": "Dev Menu", "devMenu": "Dev Menu",
"dataManagement": "Data Management", "dataManagement": "Data Management",
"clearOpenTabs": "Clear all open tabs", "clearOpenTabs": "Clear all open tabs",
+99 -69
View File
@@ -254,6 +254,30 @@ abstract class AppLocalizations {
/// **'Clear search'** /// **'Clear search'**
String get clearSearch; 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. /// No description provided for @allCustomers.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -674,6 +698,24 @@ abstract class AppLocalizations {
/// **'{category} • {price} • Stock: {stock}'** /// **'{category} • {price} • Stock: {stock}'**
String productStockSummary(String category, String price, int 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. /// No description provided for @outOfStock.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -920,24 +962,6 @@ abstract class AppLocalizations {
/// **'Update'** /// **'Update'**
String get 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. /// No description provided for @updateFailed.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -950,18 +974,6 @@ abstract class AppLocalizations {
/// **'Retry'** /// **'Retry'**
String get 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. /// No description provided for @doNotCloseDuringUpdate.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1004,47 +1016,77 @@ abstract class AppLocalizations {
/// **'Simulated download error (network timeout)'** /// **'Simulated download error (network timeout)'**
String get simulatedDownloadError; 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. /// No description provided for @installingUpdate.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Installing update…'** /// **'Installing update…'**
String get installingUpdate; 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. /// No description provided for @updateAlreadyInProgress.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Update already in progress'** /// **'Update already in progress'**
String get updateAlreadyInProgress; String get updateAlreadyInProgress;
/// No description provided for @updateAlreadyRunning. /// No description provided for @updatingTo.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Update already running'** /// **'Updating to {version}'**
String get updateAlreadyRunning; 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. /// No description provided for @genericError.
/// ///
@@ -1154,18 +1196,6 @@ abstract class AppLocalizations {
/// **'Could not save settings.'** /// **'Could not save settings.'**
String get couldNotSaveSettings; 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. /// No description provided for @devMenu.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+60 -37
View File
@@ -91,6 +91,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get clearSearch => 'Clear search'; 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 @override
String get allCustomers => 'All customers'; String get allCustomers => 'All customers';
@@ -325,6 +339,18 @@ class AppLocalizationsEn extends AppLocalizations {
return '$category • $price • Stock: $stock'; 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 @override
String get outOfStock => 'OUT OF STOCK'; String get outOfStock => 'OUT OF STOCK';
@@ -455,28 +481,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get update => 'Update'; 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 @override
String get updateFailed => 'Update failed'; String get updateFailed => 'Update failed';
@override @override
String get retry => 'Retry'; String get retry => 'Retry';
@override
String get updating => 'Updating';
@override
String get installing => 'Installing';
@override @override
String get doNotCloseDuringUpdate => 'Do not close the app during the update'; String get doNotCloseDuringUpdate => 'Do not close the app during the update';
@@ -500,28 +510,47 @@ class AppLocalizationsEn extends AppLocalizations {
String get simulatedDownloadError => String get simulatedDownloadError =>
'Simulated download error (network timeout)'; 'Simulated download error (network timeout)';
@override
String get downloadFailed => 'Download failed';
@override
String downloading(String percent) {
return 'Downloading $percent%';
}
@override @override
String get installingUpdate => 'Installing update…'; String get installingUpdate => 'Installing update…';
@override
String get updateInstalledRestarting => 'Update installed! Restarting…';
@override
String get installationFailed => 'Installation failed';
@override @override
String get updateAlreadyInProgress => 'Update already in progress'; String get updateAlreadyInProgress => 'Update already in progress';
@override @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 @override
String get genericError => 'An unexpected error occurred.'; String get genericError => 'An unexpected error occurred.';
@@ -577,12 +606,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get couldNotSaveSettings => 'Could not save settings.'; String get couldNotSaveSettings => 'Could not save settings.';
@override
String get downloadFailedWithDetail => 'Download failed.';
@override
String get installationFailedWithDetail => 'Installation failed.';
@override @override
String get devMenu => 'Dev Menu'; String get devMenu => 'Dev Menu';
+10 -38
View File
@@ -1,4 +1,5 @@
import '../models/app_error.dart'; import '../models/app_error.dart';
import '../models/update_failure.dart';
import 'app_localizations.dart'; import 'app_localizations.dart';
extension AppLocalizationsHelpers on AppLocalizations { extension AppLocalizationsHelpers on AppLocalizations {
@@ -44,47 +45,18 @@ extension AppLocalizationsHelpers on AppLocalizations {
}; };
} }
/// Update messages still come from the OTA plugin as plain strings. String updateFailureText(UpdateFailure? failure) {
String _updateMessage(String? message) { return switch (failure) {
return switch (message) { UpdateFailure.download => updateDownloadFailed,
'Update failed' => updateFailed, UpdateFailure.checksum => updateChecksumFailed,
'Download failed' => downloadFailed, UpdateFailure.permission => updatePermissionDenied,
'Installation failed' => installationFailed, UpdateFailure.install => updateInstallFailed,
'Update already in progress' => updateAlreadyInProgress, UpdateFailure.alreadyRunning => updateAlreadyInProgress,
_ => message ?? genericError, 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) { String devAction(String? message) {
if (message == null) return ''; if (message == null) return '';
+67 -44
View File
@@ -68,7 +68,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get openOrSelectTab => 'Open of selecteer eerst een poef.'; String get openOrSelectTab => 'Open of selecteer eerst een poef.';
@override @override
String get openNewTab => 'Nieuw poef openen'; String get openNewTab => 'Nieuwe poef openen';
@override @override
String get customerGroupName => 'Naam klant / groep'; String get customerGroupName => 'Naam klant / groep';
@@ -90,6 +90,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get clearSearch => 'Zoekopdracht wissen'; 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 @override
String get allCustomers => 'Alle klanten'; String get allCustomers => 'Alle klanten';
@@ -325,6 +340,18 @@ class AppLocalizationsNl extends AppLocalizations {
return '$category • $price • Voorraad: $stock'; 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 @override
String get outOfStock => 'UITVERKOCHT'; String get outOfStock => 'UITVERKOCHT';
@@ -457,28 +484,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get update => 'Updaten'; 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 @override
String get updateFailed => 'Update mislukt'; String get updateFailed => 'Update mislukt';
@override @override
String get retry => 'Opnieuw proberen'; String get retry => 'Opnieuw proberen';
@override
String get updating => 'Updaten';
@override
String get installing => 'Installeren';
@override @override
String get doNotCloseDuringUpdate => 'Sluit de app niet tijdens de update'; String get doNotCloseDuringUpdate => 'Sluit de app niet tijdens de update';
@@ -502,29 +513,47 @@ class AppLocalizationsNl extends AppLocalizations {
String get simulatedDownloadError => String get simulatedDownloadError =>
'Gesimuleerde downloadfout (netwerktime-out)'; 'Gesimuleerde downloadfout (netwerktime-out)';
@override
String get downloadFailed => 'Download mislukt';
@override
String downloading(String percent) {
return 'Downloaden $percent%';
}
@override @override
String get installingUpdate => 'Update installeren…'; String get installingUpdate => 'Update installeren…';
@override
String get updateInstalledRestarting =>
'Update geïnstalleerd! Opnieuw starten…';
@override
String get installationFailed => 'Installatie mislukt';
@override @override
String get updateAlreadyInProgress => 'Update is al bezig'; String get updateAlreadyInProgress => 'Update is al bezig';
@override @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 @override
String get genericError => 'Er is een onverwachte fout opgetreden.'; String get genericError => 'Er is een onverwachte fout opgetreden.';
@@ -584,12 +613,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get couldNotSaveSettings => String get couldNotSaveSettings =>
'Instellingen konden niet worden opgeslagen.'; 'Instellingen konden niet worden opgeslagen.';
@override
String get downloadFailedWithDetail => 'Download mislukt.';
@override
String get installationFailedWithDetail => 'Installatie mislukt.';
@override @override
String get devMenu => 'Ontwikkelaarsmenu'; String get devMenu => 'Ontwikkelaarsmenu';
@@ -659,10 +682,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get testingHelpers => 'Testhelpers'; String get testingHelpers => 'Testhelpers';
@override @override
String get createTestTab => 'Testtabblad maken'; String get createTestTab => 'Testpoef maken';
@override @override
String get addRandomItems => 'Tabblad met willekeurige items toevoegen'; String get addRandomItems => 'Poef met willekeurige items toevoegen';
@override @override
String get addTestProducts => '100 testproducten toevoegen'; String get addTestProducts => '100 testproducten toevoegen';
@@ -754,11 +777,11 @@ class AppLocalizationsNl extends AppLocalizations {
String get categoryOther => 'Overig'; String get categoryOther => 'Overig';
@override @override
String get devActionClearedOpenTabs => 'Alle open tabbladen gewist'; String get devActionClearedOpenTabs => 'Alle open poefs gewist';
@override @override
String get devActionClearedHistory => String get devActionClearedHistory =>
'Geschiedenis van gesloten tabbladen gewist'; 'Geschiedenis van gesloten poefs gewist';
@override @override
String get devActionClearedProducts => 'Alle producten gewist'; String get devActionClearedProducts => 'Alle producten gewist';
@@ -796,7 +819,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String devActionTestTab(int count) { String devActionTestTab(int count) {
return 'Testtabblad gemaakt met $count items'; return 'Testpoef gemaakt met $count items';
} }
@override @override
+26 -19
View File
@@ -19,7 +19,7 @@
"importNamesDescription": "Bereid een lijst met namen voor om later als poefs te gebruiken.", "importNamesDescription": "Bereid een lijst met namen voor om later als poefs te gebruiken.",
"importPlaceholder": "Namen importeren is hier later beschikbaar.", "importPlaceholder": "Namen importeren is hier later beschikbaar.",
"openOrSelectTab": "Open of selecteer eerst een poef.", "openOrSelectTab": "Open of selecteer eerst een poef.",
"openNewTab": "Nieuw poef openen", "openNewTab": "Nieuwe poef openen",
"customerGroupName": "Naam klant / groep", "customerGroupName": "Naam klant / groep",
"cancel": "Annuleren", "cancel": "Annuleren",
"openTab": "Poef openen", "openTab": "Poef openen",
@@ -27,6 +27,11 @@
"@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}}, "@couldNotCreateTab": {"placeholders": {"error": {"type": "String"}}},
"searchByName": "Zoeken op naam…", "searchByName": "Zoeken op naam…",
"clearSearch": "Zoekopdracht wissen", "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", "allCustomers": "Alle klanten",
"customer": "Klant", "customer": "Klant",
"noClosedTabs": "Nog geen gesloten poefs", "noClosedTabs": "Nog geen gesloten poefs",
@@ -102,6 +107,10 @@
"closeTab": "Poef sluiten", "closeTab": "Poef sluiten",
"productStockSummary": "{category} • {price} • Voorraad: {stock}", "productStockSummary": "{category} • {price} • Voorraad: {stock}",
"@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, "@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", "outOfStock": "UITVERKOCHT",
"tabsYouCloseAppearHere": "Gesloten poefs verschijnen hier.", "tabsYouCloseAppearHere": "Gesloten poefs verschijnen hier.",
"enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen", "enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen",
@@ -146,13 +155,8 @@
"@versionAvailable": {"placeholders": {"version": {"type": "String"}}}, "@versionAvailable": {"placeholders": {"version": {"type": "String"}}},
"later": "Later", "later": "Later",
"update": "Updaten", "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", "updateFailed": "Update mislukt",
"retry": "Opnieuw proberen", "retry": "Opnieuw proberen",
"updating": "Updaten",
"installing": "Installeren",
"doNotCloseDuringUpdate": "Sluit de app niet tijdens de update", "doNotCloseDuringUpdate": "Sluit de app niet tijdens de update",
"cancelUpdate": "Update annuleren?", "cancelUpdate": "Update annuleren?",
"cancelUpdateDescription": "De update is bezig. Als je nu weggaat, kan de app instabiel worden.", "cancelUpdateDescription": "De update is bezig. Als je nu weggaat, kan de app instabiel worden.",
@@ -160,14 +164,19 @@
"cancelUpdateAction": "Update annuleren", "cancelUpdateAction": "Update annuleren",
"preparingDownload": "Download voorbereiden…", "preparingDownload": "Download voorbereiden…",
"simulatedDownloadError": "Gesimuleerde downloadfout (netwerktime-out)", "simulatedDownloadError": "Gesimuleerde downloadfout (netwerktime-out)",
"downloadFailed": "Download mislukt",
"downloading": "Downloaden {percent}%",
"@downloading": {"placeholders": {"percent": {"type": "String"}}},
"installingUpdate": "Update installeren…", "installingUpdate": "Update installeren…",
"updateInstalledRestarting": "Update geïnstalleerd! Opnieuw starten…",
"installationFailed": "Installatie mislukt",
"updateAlreadyInProgress": "Update is al bezig", "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.", "genericError": "Er is een onverwachte fout opgetreden.",
"couldNotLoadBar": "Barkassa kon niet worden geladen.", "couldNotLoadBar": "Barkassa kon niet worden geladen.",
"couldNotCreateTabMessage": "Poef kon niet worden aangemaakt.", "couldNotCreateTabMessage": "Poef kon niet worden aangemaakt.",
@@ -186,8 +195,6 @@
"currentPinIncorrect": "Huidige PIN is onjuist.", "currentPinIncorrect": "Huidige PIN is onjuist.",
"couldNotLoadSettings": "Instellingen konden niet worden geladen.", "couldNotLoadSettings": "Instellingen konden niet worden geladen.",
"couldNotSaveSettings": "Instellingen konden niet worden opgeslagen.", "couldNotSaveSettings": "Instellingen konden niet worden opgeslagen.",
"downloadFailedWithDetail": "Download mislukt.",
"installationFailedWithDetail": "Installatie mislukt.",
"devMenu": "Ontwikkelaarsmenu", "devMenu": "Ontwikkelaarsmenu",
"dataManagement": "Databeheer", "dataManagement": "Databeheer",
"clearOpenTabs": "Alle open poefs wissen", "clearOpenTabs": "Alle open poefs wissen",
@@ -210,8 +217,8 @@
"simulateUpdateDownload": "Updatedownload simuleren", "simulateUpdateDownload": "Updatedownload simuleren",
"openDownloadProgress": "Downloadvoortgang openen", "openDownloadProgress": "Downloadvoortgang openen",
"testingHelpers": "Testhelpers", "testingHelpers": "Testhelpers",
"createTestTab": "Testtabblad maken", "createTestTab": "Testpoef maken",
"addRandomItems": "Tabblad met willekeurige items toevoegen", "addRandomItems": "Poef met willekeurige items toevoegen",
"addTestProducts": "100 testproducten toevoegen", "addTestProducts": "100 testproducten toevoegen",
"stressTestGrid": "Productraster stresstesten", "stressTestGrid": "Productraster stresstesten",
"simulateLowStock": "Lage voorraad simuleren", "simulateLowStock": "Lage voorraad simuleren",
@@ -240,8 +247,8 @@
"categoryCoffee": "Koffie", "categoryCoffee": "Koffie",
"categoryTea": "Thee", "categoryTea": "Thee",
"categoryOther": "Overig", "categoryOther": "Overig",
"devActionClearedOpenTabs": "Alle open tabbladen gewist", "devActionClearedOpenTabs": "Alle open poefs gewist",
"devActionClearedHistory": "Geschiedenis van gesloten tabbladen gewist", "devActionClearedHistory": "Geschiedenis van gesloten poefs gewist",
"devActionClearedProducts": "Alle producten gewist", "devActionClearedProducts": "Alle producten gewist",
"devActionSeededProducts": "{count} producten toegevoegd", "devActionSeededProducts": "{count} producten toegevoegd",
"@devActionSeededProducts": {"placeholders": {"count": {"type": "int"}}}, "@devActionSeededProducts": {"placeholders": {"count": {"type": "int"}}},
@@ -254,7 +261,7 @@
"devActionCacheCleared": "Afbeeldingencache gewist (geen actie in debugmodus)", "devActionCacheCleared": "Afbeeldingencache gewist (geen actie in debugmodus)",
"devActionLowStock": "Lage voorraad gesimuleerd voor {count} producten", "devActionLowStock": "Lage voorraad gesimuleerd voor {count} producten",
"@devActionLowStock": {"placeholders": {"count": {"type": "int"}}}, "@devActionLowStock": {"placeholders": {"count": {"type": "int"}}},
"devActionTestTab": "Testtabblad gemaakt met {count} items", "devActionTestTab": "Testpoef gemaakt met {count} items",
"@devActionTestTab": {"placeholders": {"count": {"type": "int"}}}, "@devActionTestTab": {"placeholders": {"count": {"type": "int"}}},
"devActionTestProducts": "{count} testproducten toegevoegd", "devActionTestProducts": "{count} testproducten toegevoegd",
"@devActionTestProducts": {"placeholders": {"count": {"type": "int"}}}, "@devActionTestProducts": {"placeholders": {"count": {"type": "int"}}},
+11 -1
View File
@@ -4,6 +4,9 @@ class Product {
final String category; final String category;
final int stockQuantity; final int stockQuantity;
final int lowStockThreshold; final int lowStockThreshold;
/// Never runs out; [stockQuantity] is left untouched by sales.
final bool infiniteStock;
final int priceInCents; final int priceInCents;
final String? imagePath; final String? imagePath;
final bool active; final bool active;
@@ -14,12 +17,15 @@ class Product {
required this.category, required this.category,
required this.stockQuantity, required this.stockQuantity,
required this.lowStockThreshold, required this.lowStockThreshold,
this.infiniteStock = false,
required this.priceInCents, required this.priceInCents,
this.imagePath, this.imagePath,
this.active = true, this.active = true,
}); });
bool get isLowStock => stockQuantity <= lowStockThreshold; bool get isOutOfStock => !infiniteStock && stockQuantity <= 0;
bool get isLowStock => !infiniteStock && stockQuantity <= lowStockThreshold;
Product copyWith({ Product copyWith({
String? id, String? id,
@@ -27,6 +33,7 @@ class Product {
String? category, String? category,
int? stockQuantity, int? stockQuantity,
int? lowStockThreshold, int? lowStockThreshold,
bool? infiniteStock,
int? priceInCents, int? priceInCents,
String? imagePath, String? imagePath,
bool? active, bool? active,
@@ -37,6 +44,7 @@ class Product {
category: category ?? this.category, category: category ?? this.category,
stockQuantity: stockQuantity ?? this.stockQuantity, stockQuantity: stockQuantity ?? this.stockQuantity,
lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold,
infiniteStock: infiniteStock ?? this.infiniteStock,
priceInCents: priceInCents ?? this.priceInCents, priceInCents: priceInCents ?? this.priceInCents,
imagePath: imagePath ?? this.imagePath, imagePath: imagePath ?? this.imagePath,
active: active ?? this.active, active: active ?? this.active,
@@ -52,6 +60,7 @@ class Product {
category == other.category && category == other.category &&
stockQuantity == other.stockQuantity && stockQuantity == other.stockQuantity &&
lowStockThreshold == other.lowStockThreshold && lowStockThreshold == other.lowStockThreshold &&
infiniteStock == other.infiniteStock &&
priceInCents == other.priceInCents && priceInCents == other.priceInCents &&
imagePath == other.imagePath && imagePath == other.imagePath &&
active == other.active; active == other.active;
@@ -63,6 +72,7 @@ class Product {
category, category,
stockQuantity, stockQuantity,
lowStockThreshold, lowStockThreshold,
infiniteStock,
priceInCents, priceInCents,
imagePath, imagePath,
active, active,
+11
View File
@@ -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,
}
+5
View File
@@ -21,6 +21,7 @@ abstract class ProductService {
required String category, required String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
bool infiniteStock = false,
required int priceInCents, required int priceInCents,
required String? imagePath, required String? imagePath,
}); });
@@ -47,6 +48,7 @@ class DriftProductService implements ProductService {
category: row.category, category: row.category,
stockQuantity: row.stockQuantity, stockQuantity: row.stockQuantity,
lowStockThreshold: row.lowStockThreshold, lowStockThreshold: row.lowStockThreshold,
infiniteStock: row.infiniteStock,
priceInCents: row.priceInCents, priceInCents: row.priceInCents,
imagePath: row.imagePath, imagePath: row.imagePath,
active: row.active, active: row.active,
@@ -89,6 +91,7 @@ class DriftProductService implements ProductService {
required String category, required String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
bool infiniteStock = false,
required int priceInCents, required int priceInCents,
required String? imagePath, required String? imagePath,
}) async { }) async {
@@ -101,6 +104,7 @@ class DriftProductService implements ProductService {
category: category, category: category,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: Value(infiniteStock),
priceInCents: priceInCents, priceInCents: priceInCents,
imagePath: Value(imagePath), imagePath: Value(imagePath),
), ),
@@ -118,6 +122,7 @@ class DriftProductService implements ProductService {
category: Value(product.category), category: Value(product.category),
stockQuantity: Value(product.stockQuantity), stockQuantity: Value(product.stockQuantity),
lowStockThreshold: Value(product.lowStockThreshold), lowStockThreshold: Value(product.lowStockThreshold),
infiniteStock: Value(product.infiniteStock),
priceInCents: Value(product.priceInCents), priceInCents: Value(product.priceInCents),
imagePath: Value(product.imagePath), imagePath: Value(product.imagePath),
active: Value(product.active), active: Value(product.active),
+35
View File
@@ -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 String category,
required int stockQuantity, required int stockQuantity,
required int lowStockThreshold, required int lowStockThreshold,
required bool infiniteStock,
required int priceInCents, required int priceInCents,
required String? imagePath, required String? imagePath,
}) { }) {
@@ -55,6 +56,7 @@ class ProductListViewModel extends ChangeNotifier {
category: category, category: category,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: infiniteStock,
priceInCents: priceInCents, priceInCents: priceInCents,
imagePath: imagePath, imagePath: imagePath,
), ),
+142 -111
View File
@@ -2,31 +2,45 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:ota_update/ota_update.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 'package:sentry_flutter/sentry_flutter.dart';
import '../models/update_failure.dart';
import '../utils/app_config.dart'; import '../utils/app_config.dart';
import '../utils/app_update_util.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 { class UpdateProgressViewModel extends ChangeNotifier {
final UpdateInfo update; final UpdateInfo update;
final bool simulate; final bool simulate;
final bool simulateError; final bool simulateError;
OtaPhase _phase = OtaPhase.downloading; UpdatePhase _phase = UpdatePhase.downloading;
double _progress = 0; double? _progress;
String _statusText = 'Preparing download…'; UpdateFailure? _failure;
String? _errorMessage; String? _currentVersion;
bool _done = false;
StreamSubscription? _subscription; StreamSubscription<OtaEvent>? _subscription;
Timer? _simTimer; Timer? _simTimer;
OtaPhase get phase => _phase; UpdatePhase get phase => _phase;
double get progress => _progress;
String get statusText => _statusText; /// Download progress from 0 to 1, or null while it is not known yet.
String? get errorMessage => _errorMessage; double? get progress => _progress;
UpdateFailure? get failure => _failure;
String? get currentVersion => _currentVersion;
bool get isBusy =>
_phase == UpdatePhase.downloading || _phase == UpdatePhase.installing;
UpdateProgressViewModel({ UpdateProgressViewModel({
required this.update, required this.update,
@@ -34,7 +48,18 @@ class UpdateProgressViewModel extends ChangeNotifier {
this.simulateError = false, 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) { if (simulate) {
_runSimulation(); _runSimulation();
return; return;
@@ -44,8 +69,6 @@ class UpdateProgressViewModel extends ChangeNotifier {
? update.download ? update.download
: '$kUpdateServerUrl${update.download}'; : '$kUpdateServerUrl${update.download}';
debugPrint('UpdateProgressViewModel: starting download from $url');
_subscription = OtaUpdate() _subscription = OtaUpdate()
.execute( .execute(
url, url,
@@ -55,118 +78,126 @@ class UpdateProgressViewModel extends ChangeNotifier {
.listen(_onEvent, onError: _onError, onDone: _onDone); .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() { void _runSimulation() {
debugPrint('UpdateProgressViewModel: running simulated download'); const totalSteps = 40;
var step = 0;
int step = 0; _simTimer = Timer.periodic(const Duration(milliseconds: 150), (timer) {
const totalSteps = 20;
const stepDuration = Duration(milliseconds: 500);
_simTimer = Timer.periodic(stepDuration, (timer) {
step++; step++;
if (simulateError && step == 10) { if (simulateError && step == totalSteps ~/ 2) {
_phase = UpdatePhase.failed;
_failure = UpdateFailure.simulated;
timer.cancel(); timer.cancel();
_phase = OtaPhase.error;
_errorMessage = 'Simulated download error (network timeout)';
_statusText = 'Download failed';
notifyListeners(); notifyListeners();
return; return;
} }
if (step < totalSteps) { if (step < totalSteps) {
_progress = step / totalSteps; _progress = step / totalSteps;
_statusText = 'Downloading ${(_progress * 100).toStringAsFixed(0)}%';
_phase = OtaPhase.downloading;
notifyListeners(); 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; return;
}
case OtaStatus.DOWNLOAD_ERROR: timer.cancel();
_phase = OtaPhase.error; _progress = 1;
_errorMessage = 'Download failed: ${event.value}'; _phase = UpdatePhase.installing;
_statusText = 'Download failed'; notifyListeners();
Sentry.captureMessage('OTA download error: ${event.value}');
_simTimer = Timer(const Duration(seconds: 2), () {
_phase = UpdatePhase.waitingForInstaller;
notifyListeners(); 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 @override
+17
View File
@@ -7,6 +7,7 @@ import '../models/remote_command.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/admin_pin_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart';
import 'dialogs/new_tab_dialog.dart';
class AdminScreenView extends StatelessWidget { class AdminScreenView extends StatelessWidget {
const AdminScreenView({super.key}); const AdminScreenView({super.key});
@@ -17,6 +18,16 @@ class AdminScreenView extends StatelessWidget {
adminPinViewModel.lock(); 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 { Future<void> _confirmUnlink(BuildContext context) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -80,6 +91,12 @@ class AdminScreenView extends StatelessWidget {
_AdminSection( _AdminSection(
title: l10n.adminTools, title: l10n.adminTools,
children: [ children: [
_AdminTile(
icon: Icons.person_add_alt_1_outlined,
title: l10n.openNewTab,
subtitle: l10n.openNewTabDescription,
onTap: () => _openNewTab(context),
),
_AdminTile( _AdminTile(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
title: l10n.products, title: l10n.products,
+11 -3
View File
@@ -19,7 +19,6 @@ import '../../viewmodels/remote_sync_view_model.dart';
import '../../viewmodels/settings_view_model.dart'; import '../../viewmodels/settings_view_model.dart';
import '../dialogs/close_tab_dialog.dart'; import '../dialogs/close_tab_dialog.dart';
import '../dialogs/edit_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart';
import '../dialogs/new_tab_dialog.dart';
import 'product_grid.dart'; import 'product_grid.dart';
import 'tab_panel.dart'; import 'tab_panel.dart';
@@ -31,6 +30,7 @@ class BarScreenView extends StatefulWidget {
} }
class _BarScreenViewState extends State<BarScreenView> { class _BarScreenViewState extends State<BarScreenView> {
bool _showSearch = false;
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription; late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
@override @override
@@ -110,13 +110,21 @@ class _BarScreenViewState extends State<BarScreenView> {
final settings = context.watch<SettingsViewModel>().settings; final settings = context.watch<SettingsViewModel>().settings;
final stockByProductId = { final stockByProductId = {
for (final product in productsViewModel.products) for (final product in productsViewModel.products)
product.id: product.stockQuantity, if (!product.infiniteStock) product.id: product.stockQuantity,
}; };
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l10n.barTabs), title: Text(l10n.barTabs),
actions: [ 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( IconButton(
tooltip: l10n.admin, tooltip: l10n.admin,
onPressed: () => context.push('/admin'), onPressed: () => context.push('/admin'),
@@ -190,7 +198,7 @@ class _BarScreenViewState extends State<BarScreenView> {
selectedTab: viewModel.selectedTab, selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId, selectedTabId: viewModel.selectedTabId,
stockByProductId: stockByProductId, stockByProductId: stockByProductId,
onNewTabPressed: () => showNewTabDialog(context), showSearch: _showSearch,
onTabSelected: viewModel.selectTab, onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity, onItemQuantityChanged: viewModel.changeItemQuantity,
onCloseTabPressed: () => confirmCloseTab(context), onCloseTabPressed: () => confirmCloseTab(context),
+25 -20
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -111,33 +113,36 @@ class ProductGrid extends StatelessWidget {
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
const horizontalPadding = 32.0; const padding = EdgeInsets.fromLTRB(16, 12, 16, 16);
const verticalPadding = 28.0;
const spacing = 12.0; 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 rowCount = rows.clamp(2, 6).toInt();
final rowHeight = final tileHeight = max(
((constraints.maxHeight - minTileHeight,
verticalPadding - (constraints.maxHeight -
(rowCount - 1) * spacing) / padding.vertical -
rowCount) (rowCount - 1) * spacing) /
.clamp(96.0, 320.0) rowCount,
.toDouble(); );
final columns = final columns = max(
(((constraints.maxWidth - horizontalPadding + spacing) / 1,
(rowHeight + spacing)) ((constraints.maxWidth - padding.horizontal + spacing) /
.ceil()) (tileHeight + spacing))
.clamp(2, 8) .round(),
.toInt(); );
return GridView.builder( return GridView.builder(
key: ValueKey('product-grid-$rows'), key: ValueKey('product-grid-$rowCount'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: padding,
itemCount: products.length, itemCount: products.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns, crossAxisCount: columns,
crossAxisSpacing: 12, crossAxisSpacing: spacing,
mainAxisSpacing: 12, mainAxisSpacing: spacing,
childAspectRatio: 1, mainAxisExtent: tileHeight,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final product = products[index]; final product = products[index];
+395 -161
View File
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart'; import '../../models/bar_tab.dart';
import '../../models/tab_item.dart'; import '../../models/tab_item.dart';
import '../../utils/money.dart'; import '../../utils/money.dart';
import '../../utils/name_index.dart';
import 'selected_tab_details.dart'; import 'selected_tab_details.dart';
class TabPanel extends StatefulWidget { class TabPanel extends StatefulWidget {
@@ -12,7 +17,7 @@ class TabPanel extends StatefulWidget {
final BarTab? selectedTab; final BarTab? selectedTab;
final String? selectedTabId; final String? selectedTabId;
final Map<String, int> stockByProductId; final Map<String, int> stockByProductId;
final VoidCallback onNewTabPressed; final bool showSearch;
final ValueChanged<String> onTabSelected; final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged; final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed; final VoidCallback onCloseTabPressed;
@@ -25,7 +30,7 @@ class TabPanel extends StatefulWidget {
required this.selectedTab, required this.selectedTab,
required this.selectedTabId, required this.selectedTabId,
required this.stockByProductId, required this.stockByProductId,
required this.onNewTabPressed, required this.showSearch,
required this.onTabSelected, required this.onTabSelected,
required this.onItemQuantityChanged, required this.onItemQuantityChanged,
required this.onCloseTabPressed, required this.onCloseTabPressed,
@@ -39,14 +44,76 @@ class TabPanel extends StatefulWidget {
class _TabPanelState extends State<TabPanel> { class _TabPanelState extends State<TabPanel> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _tabsScrollController = ScrollController();
String _searchQuery = ''; 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_tabsScrollController.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -56,95 +123,115 @@ class _TabPanelState extends State<TabPanel> {
return tab.customerName.toLowerCase().contains( return tab.customerName.toLowerCase().contains(
_searchQuery.toLowerCase(), _searchQuery.toLowerCase(),
); );
}).toList(); }).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration(color: scheme.surfaceContainerLow), decoration: BoxDecoration(color: scheme.surfaceContainerLow),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(4, 16, 16, 16),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( ListenableBuilder(
children: [ listenable: _tabsScrollController,
Expanded( builder: (context, child) => _LetterIndexBar(
child: TextField( availableLetters: {
controller: _searchController, for (final tab in filteredTabs)
onChanged: (value) { indexLetterOf(tab.customerName),
setState(() { },
_searchQuery = value; activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs),
}); isTouched: _touchedLetter != null,
}, onLetterSelected: (letter) =>
decoration: InputDecoration( _jumpToLetter(letter, filteredTabs),
hintText: l10n.searchByName, onReleased: () => setState(() => _touchedLetter = null),
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,
), ),
), ),
const SizedBox(width: 8),
const Divider(height: 28),
Expanded( Expanded(
child: widget.selectedTab == null child: Column(
? Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
mainAxisSize: MainAxisSize.min, // Fixed height, so hiding the search bar gives its space
children: [ // to the names
Icon( SizedBox(
Icons.receipt_long_outlined, height: 246,
size: 36, child: Column(
color: scheme.onSurface.withValues(alpha: 0.25), children: [
), if (widget.showSearch) ...[
const SizedBox(height: 10), TextField(
Text( autofocus: true,
l10n.selectOrOpenTab, controller: _searchController,
style: Theme.of(context).textTheme.bodyMedium, 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),
], ],
), Expanded(
) child: OpenTabsList(
: SelectedTabDetails( controller: _tabsScrollController,
tab: widget.selectedTab!, tabs: filteredTabs,
stockByProductId: widget.stockByProductId, selectedTabId: widget.selectedTabId,
onItemQuantityChanged: widget.onItemQuantityChanged, onTabSelected: widget.onTabSelected,
onCloseTabPressed: widget.onCloseTabPressed, 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 { class OpenTabsList extends StatelessWidget {
final ScrollController? controller;
final List<BarTab> tabs; final List<BarTab> tabs;
final String? selectedTabId; final String? selectedTabId;
final ValueChanged<String> onTabSelected; final ValueChanged<String> onTabSelected;
@@ -162,6 +250,7 @@ class OpenTabsList extends StatelessWidget {
const OpenTabsList({ const OpenTabsList({
super.key, super.key,
this.controller,
required this.tabs, required this.tabs,
required this.selectedTabId, required this.selectedTabId,
required this.onTabSelected, 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, 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) { itemBuilder: (context, index) {
final tab = tabs[index]; final tab = tabs[index];
final selected = tab.id == selectedTabId;
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
return ClipRRect( return _TabRow(
borderRadius: BorderRadius.circular(14), child: ClipRRect(
child: Slidable( borderRadius: BorderRadius.circular(14),
key: ValueKey(tab.id), child: Slidable(
endActionPane: ActionPane( key: ValueKey(tab.id),
motion: const DrawerMotion(), endActionPane: ActionPane(
extentRatio: 0.6, motion: const DrawerMotion(),
children: [ extentRatio: 0.6,
SlidableAction( children: [
onPressed: (_) => onTabEdited(tab), SlidableAction(
icon: Icons.edit_outlined, onPressed: (_) => onTabEdited(tab),
label: l10n.edit, icon: Icons.edit_outlined,
backgroundColor: Theme.of(context).colorScheme.secondary, label: l10n.edit,
foregroundColor: Theme.of(context).colorScheme.onSecondary, 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,
),
],
),
),
), ),
), 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,
),
),
);
}
}
+12 -9
View File
@@ -4,7 +4,8 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../viewmodels/bar_screen_view_model.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 l10n = AppLocalizations.of(context);
final controller = TextEditingController(); final controller = TextEditingController();
@@ -38,16 +39,18 @@ Future<void> showNewTabDialog(BuildContext context) async {
}, },
); );
if (name == null || name.trim().isEmpty) return; final trimmed = name?.trim() ?? '';
if (trimmed.isEmpty || !context.mounted) return null;
if (!context.mounted) return;
try { try {
await context.read<BarScreenViewModel>().createTab(name.trim()); await context.read<BarScreenViewModel>().createTab(trimmed);
return trimmed;
} catch (_) { } catch (_) {
if (!context.mounted) return; if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage)));
}
return null;
} }
} }
+57 -35
View File
@@ -39,6 +39,7 @@ class _ProductFormViewState extends State<ProductFormView> {
Product? _existingProduct; Product? _existingProduct;
String? _imagePath; String? _imagePath;
bool _hasImage = false; bool _hasImage = false;
bool _infiniteStock = false;
bool _isLoading = true; bool _isLoading = true;
bool _isSaving = false; bool _isSaving = false;
@@ -79,6 +80,7 @@ class _ProductFormViewState extends State<ProductFormView> {
_priceController.text = (product.priceInCents / 100).toStringAsFixed(2); _priceController.text = (product.priceInCents / 100).toStringAsFixed(2);
_stockController.text = product.stockQuantity.toString(); _stockController.text = product.stockQuantity.toString();
_lowStockController.text = product.lowStockThreshold.toString(); _lowStockController.text = product.lowStockThreshold.toString();
_infiniteStock = product.infiniteStock;
_imagePath = product.imagePath; _imagePath = product.imagePath;
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; _hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
@@ -189,6 +191,7 @@ class _ProductFormViewState extends State<ProductFormView> {
priceInCents: priceInCents, priceInCents: priceInCents,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: _infiniteStock,
imagePath: _imagePath, imagePath: _imagePath,
); );
@@ -200,6 +203,7 @@ class _ProductFormViewState extends State<ProductFormView> {
priceInCents: priceInCents, priceInCents: priceInCents,
stockQuantity: stockQuantity, stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold, lowStockThreshold: lowStockThreshold,
infiniteStock: _infiniteStock,
imagePath: _imagePath, imagePath: _imagePath,
); );
} }
@@ -429,46 +433,64 @@ class _ProductFormViewState extends State<ProductFormView> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( SwitchListTile(
controller: _stockController, value: _infiniteStock,
decoration: InputDecoration( onChanged: (value) {
labelText: l10n.stock, setState(() => _infiniteStock = value);
border: OutlineInputBorder(), },
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), if (!_infiniteStock) ...[
TextFormField( const SizedBox(height: 16),
controller: _lowStockController, TextFormField(
decoration: InputDecoration( controller: _stockController,
labelText: l10n.lowStockThreshold, decoration: InputDecoration(
border: OutlineInputBorder(), 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, const SizedBox(height: 16),
textInputAction: TextInputAction.done, TextFormField(
onFieldSubmitted: (_) { controller: _lowStockController,
FocusScope.of(context).unfocus(); decoration: InputDecoration(
}, labelText: l10n.lowStockThreshold,
validator: (value) { border: OutlineInputBorder(),
final number = int.tryParse(value ?? ''); ),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
FocusScope.of(context).unfocus();
},
validator: (value) {
final number = int.tryParse(value ?? '');
if (number == null || number < 0) { if (number == null || number < 0) {
return l10n.enterValidThreshold; return l10n.enterValidThreshold;
} }
return null; return null;
}, },
), ),
],
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: _isSaving ? null : _save, onPressed: _isSaving ? null : _save,
+10 -5
View File
@@ -90,11 +90,16 @@ class _ProductListViewState extends State<ProductListView> {
), ),
title: Text(product.name), title: Text(product.name),
subtitle: Text( subtitle: Text(
l10n.productStockSummary( product.infiniteStock
l10n.categoryLabel(product.category), ? l10n.productInfiniteStockSummary(
product.priceInCents.asEuro, l10n.categoryLabel(product.category),
product.stockQuantity, product.priceInCents.asEuro,
), )
: l10n.productStockSummary(
l10n.categoryLabel(product.category),
product.priceInCents.asEuro,
product.stockQuantity,
),
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/products/${product.id}/edit'), onTap: () => context.push('/products/${product.id}/edit'),
+388 -226
View File
@@ -32,112 +32,74 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
update: widget.update, update: widget.update,
simulate: widget.simulate, simulate: widget.simulate,
simulateError: widget.simulateError, simulateError: widget.simulateError,
); )..init();
_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();
},
),
),
);
} }
@override @override
void dispose() { void dispose() {
_vm.removeListener(_onVmChange);
_vm.dispose(); _vm.dispose();
super.dispose(); super.dispose();
} }
void _close() {
if (_vm.isBusy) {
_showCancelDialog();
return;
}
_vm.cancel();
context.popOrGo('/bar');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (!didPop) _showCancelDialog(); if (!didPop) _close();
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(icon: const Icon(Icons.close), onPressed: _close),
icon: const Icon(Icons.close),
onPressed: () => _showCancelDialog(),
),
title: Text(l10n.updating),
centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: _vm, listenable: _vm,
builder: (context, child) { builder: (context, child) {
return Center( return SafeArea(
child: ConstrainedBox( child: Center(
constraints: const BoxConstraints(maxWidth: 480), child: SingleChildScrollView(
child: Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 32),
padding: const EdgeInsets.all(32), child: ConstrainedBox(
child: Column( constraints: const BoxConstraints(maxWidth: 520),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
_buildHeader(), children: [
const SizedBox(height: 40), _Header(vm: _vm),
_buildProgress(_vm), const SizedBox(height: 32),
const SizedBox(height: 24), AnimatedSwitcher(
_buildStatus(_vm), duration: const Duration(milliseconds: 250),
const Spacer(), child: switch (_vm.phase) {
_buildVersionInfo(_vm), 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() { void _showCancelDialog() {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: Text(l10n.cancelUpdate), title: Text(l10n.cancelUpdate),
content: Text(l10n.cancelUpdateDescription), content: Text(l10n.cancelUpdateDescription),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: Text(l10n.continueUpdate), child: Text(l10n.continueUpdate),
), ),
FilledButton( FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(dialogContext).colorScheme.error,
), ),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(dialogContext);
_vm.cancel(); _vm.cancel();
this.context.popOrGo('/bar'); context.popOrGo('/bar');
}, },
child: Text(l10n.cancelUpdateAction), 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),
),
),
],
);
}
}
+5 -7
View File
@@ -26,10 +26,8 @@ class ProductTile extends StatelessWidget {
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final outOfStock = product.stockQuantity <= 0; final outOfStock = product.isOutOfStock;
final lowStock = final lowStock = !outOfStock && product.isLowStock;
product.stockQuantity > 0 &&
product.stockQuantity <= product.lowStockThreshold;
final hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; final hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
@@ -142,11 +140,11 @@ class _StockBadge extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
if (product.stockQuantity <= 0) { if (product.isOutOfStock) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final low = product.stockQuantity <= product.lowStockThreshold; final low = product.isLowStock;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -157,7 +155,7 @@ class _StockBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
'${product.stockQuantity}', product.infiniteStock ? '∞' : '${product.stockQuantity}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
+31 -15
View File
@@ -7,7 +7,8 @@ $ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot Set-Location $PSScriptRoot
$updateServer = 'https://updater.kooltap.com' $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) { function Fail([string]$message) {
Write-Host $message -ForegroundColor Red Write-Host $message -ForegroundColor Red
@@ -16,7 +17,7 @@ function Fail([string]$message) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$pubspec = [IO.File]::ReadAllText('pubspec.yaml') $pubspec = [IO.File]::ReadAllText($pubspecPath)
$match = [regex]::Match( $match = [regex]::Match(
$pubspec, $pubspec,
'(?m)^version:[ \t]*([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)' '(?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 $currentBuild = [int]$match.Groups[2].Value
Write-Host "Current version: $currentName+$currentBuild" Write-Host "Current version: $currentName+$currentBuild"
# The server picks the latest release by build number, so every release needs # The server picks the latest release by Android version code, so every release
# a higher one than anything already published # needs a higher one than anything already published
$publishedName = $null $publishedName = $null
$publishedBuild = 0 $publishedCode = 0
try { try {
foreach ($release in Invoke-RestMethod "$updateServer/api/releases") { $releases = Invoke-RestMethod "$updateServer/api/releases" -UseBasicParsing -TimeoutSec 15
if ($release.VersionCode -gt $publishedBuild) { foreach ($release in $releases) {
if ($release.VersionCode -gt $publishedCode) {
$publishedName = $release.Version $publishedName = $release.Version
$publishedBuild = [int]$release.VersionCode $publishedCode = [int]$release.VersionCode
} }
} }
if ($publishedName) { if ($publishedName) {
Write-Host "Latest published: $publishedName+$publishedBuild" Write-Host "Latest published: $publishedName (version code $publishedCode)"
} }
} catch { } 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)' $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." 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" $version = "$name+$build"
$versionStart = $match.Groups[1].Index $versionStart = $match.Groups[1].Index
$versionEnd = $match.Groups[2].Index + $match.Groups[2].Length $versionEnd = $match.Groups[2].Index + $match.Groups[2].Length
$pubspec = $pubspec.Substring(0, $versionStart) + $version + $pubspec.Substring($versionEnd) $pubspec = $pubspec.Substring(0, $versionStart) + $version + $pubspec.Substring($versionEnd)
[IO.File]::WriteAllText((Resolve-Path 'pubspec.yaml'), $pubspec) [IO.File]::WriteAllText($pubspecPath, $pubspec)
Write-Host "Updated pubspec.yaml to version $version." Write-Host "Updated pubspec.yaml to version $version (version code $versionCode)."
if (-not $SkipTests) { if (-not $SkipTests) {
Write-Host 'Running tests...' Write-Host 'Running tests...'
@@ -71,7 +87,7 @@ if (-not $SkipTests) {
} }
Write-Host 'Building release APK...' 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.' } if ($LASTEXITCODE -ne 0) { Fail 'Release APK build failed.' }
Write-Host "Release APK created at $apkPath" Write-Host "Release APK created at $apkPath"
@@ -93,7 +109,7 @@ $client = [Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromMinutes(5) $client.Timeout = [TimeSpan]::FromMinutes(5)
$client.DefaultRequestHeaders.Add('X-Admin-Token', $token) $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 = [Net.Http.MultipartFormDataContent]::new()
$form.Add([Net.Http.StreamContent]::new($apk), 'apk', 'app-release.apk') $form.Add([Net.Http.StreamContent]::new($apk), 'apk', 'app-release.apk')
$form.Add([Net.Http.StringContent]::new($notes), 'notes') $form.Add([Net.Http.StringContent]::new($notes), 'notes')
+1 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs." description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none' publish_to: 'none'
version: 1.2.1+1 version: 1.2.7+0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
+15 -1
View File
@@ -2,7 +2,7 @@ import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/database/app_database.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 = [ const _v12Schema = [
'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, ' 'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, '
"status TEXT NOT NULL DEFAULT 'open', opened_at INTEGER 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, ' 'CREATE TABLE tab_item_purchases (id TEXT NOT NULL, '
'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, ' 'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, '
'removed_at INTEGER NULL, PRIMARY KEY (id))', '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 bar_tabs VALUES ('tab-1', 'Alice', 'open', 1700000000, NULL, 4)",
"INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, " "INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, "
'1700000100)', '1700000100)',
@@ -68,6 +75,13 @@ void main() {
expect(purchases.where((row) => row.removedAt != null), hasLength(1)); 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 { test('deletes purchases whose item no longer exists', () async {
final orphan = await (database.select( final orphan = await (database.select(
database.tabItemPurchases, database.tabItemPurchases,
+72
View File
@@ -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',
);
});
}
}
}
+59
View File
@@ -319,5 +319,64 @@ void main() {
expect(product!.stockQuantity, 0); 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);
});
});
}); });
} }
+143
View File
@@ -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');
});
});
}