feat: alphabet list, change Layout etc
This commit is contained in:
+9
-1
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
))
|
||||||
|
.write(
|
||||||
ProductsCompanion.custom(
|
ProductsCompanion.custom(
|
||||||
stockQuantity: products.stockQuantity + Variable(amount),
|
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
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// Why an over-the-air update stopped. Views translate it with
|
||||||
|
/// `AppLocalizations.updateFailureText`.
|
||||||
|
enum UpdateFailure {
|
||||||
|
download,
|
||||||
|
checksum,
|
||||||
|
permission,
|
||||||
|
install,
|
||||||
|
alreadyRunning,
|
||||||
|
simulated,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ abstract class ProductService {
|
|||||||
required String category,
|
required 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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:
|
|
||||||
_phase = OtaPhase.error;
|
|
||||||
_errorMessage = 'Download failed: ${event.value}';
|
|
||||||
_statusText = 'Download failed';
|
|
||||||
Sentry.captureMessage('OTA download error: ${event.value}');
|
|
||||||
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) {
|
timer.cancel();
|
||||||
debugPrint('UpdateProgressViewModel: stream error: $error');
|
_progress = 1;
|
||||||
_phase = OtaPhase.error;
|
_phase = UpdatePhase.installing;
|
||||||
_errorMessage = error.toString();
|
|
||||||
_statusText = 'Update failed';
|
|
||||||
Sentry.captureException(error, stackTrace: stack);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
|
||||||
|
|
||||||
void cancel() {
|
_simTimer = Timer(const Duration(seconds: 2), () {
|
||||||
_simTimer?.cancel();
|
_phase = UpdatePhase.waitingForInstaller;
|
||||||
_simTimer = null;
|
notifyListeners();
|
||||||
_subscription?.cancel();
|
});
|
||||||
_subscription = null;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 -
|
||||||
|
padding.vertical -
|
||||||
(rowCount - 1) * spacing) /
|
(rowCount - 1) * spacing) /
|
||||||
rowCount)
|
rowCount,
|
||||||
.clamp(96.0, 320.0)
|
);
|
||||||
.toDouble();
|
final columns = max(
|
||||||
final columns =
|
1,
|
||||||
(((constraints.maxWidth - horizontalPadding + spacing) /
|
((constraints.maxWidth - padding.horizontal + spacing) /
|
||||||
(rowHeight + spacing))
|
(tileHeight + spacing))
|
||||||
.ceil())
|
.round(),
|
||||||
.clamp(2, 8)
|
);
|
||||||
.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];
|
||||||
|
|||||||
+273
-39
@@ -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,19 +123,43 @@ 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: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: _tabsScrollController,
|
||||||
|
builder: (context, child) => _LetterIndexBar(
|
||||||
|
availableLetters: {
|
||||||
|
for (final tab in filteredTabs)
|
||||||
|
indexLetterOf(tab.customerName),
|
||||||
|
},
|
||||||
|
activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs),
|
||||||
|
isTouched: _touchedLetter != null,
|
||||||
|
onLetterSelected: (letter) =>
|
||||||
|
_jumpToLetter(letter, filteredTabs),
|
||||||
|
onReleased: () => setState(() => _touchedLetter = null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// Fixed height, so hiding the search bar gives its space
|
||||||
|
// to the names
|
||||||
|
SizedBox(
|
||||||
|
height: 246,
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
if (widget.showSearch) ...[
|
||||||
child: TextField(
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -93,23 +184,11 @@ class _TabPanelState extends State<TabPanel> {
|
|||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
|
|
||||||
IconButton.filled(
|
|
||||||
onPressed: widget.onNewTabPressed,
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
tooltip: l10n.openNewTab,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
SizedBox(
|
Expanded(
|
||||||
height: 190,
|
|
||||||
child: OpenTabsList(
|
child: OpenTabsList(
|
||||||
|
controller: _tabsScrollController,
|
||||||
tabs: filteredTabs,
|
tabs: filteredTabs,
|
||||||
selectedTabId: widget.selectedTabId,
|
selectedTabId: widget.selectedTabId,
|
||||||
onTabSelected: widget.onTabSelected,
|
onTabSelected: widget.onTabSelected,
|
||||||
@@ -117,6 +196,9 @@ class _TabPanelState extends State<TabPanel> {
|
|||||||
onTabDeleted: widget.onTabDeleted,
|
onTabDeleted: widget.onTabDeleted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(height: 28),
|
const Divider(height: 28),
|
||||||
|
|
||||||
@@ -129,7 +211,9 @@ class _TabPanelState extends State<TabPanel> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.receipt_long_outlined,
|
Icons.receipt_long_outlined,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: scheme.onSurface.withValues(alpha: 0.25),
|
color: scheme.onSurface.withValues(
|
||||||
|
alpha: 0.25,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
@@ -149,11 +233,15 @@ 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,16 +270,17 @@ 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(
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Slidable(
|
child: Slidable(
|
||||||
key: ValueKey(tab.id),
|
key: ValueKey(tab.id),
|
||||||
@@ -214,7 +304,44 @@ class OpenTabsList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: AnimatedContainer(
|
child: _TabCard(
|
||||||
|
tab: tab,
|
||||||
|
selected: tab.id == selectedTabId,
|
||||||
|
onTap: () => onTabSelected(tab.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
duration: const Duration(milliseconds: 150),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected
|
color: selected
|
||||||
@@ -232,12 +359,9 @@ class OpenTabsList extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
onTap: () => onTabSelected(tab.id),
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
horizontal: 16,
|
|
||||||
vertical: 14,
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -256,10 +380,7 @@ class OpenTabsList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
l10n.tabItemSummary(
|
l10n.tabItemSummary(tab.itemCount, tab.totalInCents.asEuro),
|
||||||
tab.itemCount,
|
|
||||||
tab.totalInCents.asEuro,
|
|
||||||
),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -268,6 +389,71 @@ class OpenTabsList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -275,3 +461,51 @@ class OpenTabsList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _IndexLetter extends StatelessWidget {
|
||||||
|
final String letter;
|
||||||
|
final double fontSize;
|
||||||
|
final bool isActive;
|
||||||
|
final bool isTouched;
|
||||||
|
final bool hasNames;
|
||||||
|
final ColorScheme scheme;
|
||||||
|
|
||||||
|
const _IndexLetter({
|
||||||
|
required this.letter,
|
||||||
|
required this.fontSize,
|
||||||
|
required this.isActive,
|
||||||
|
required this.isTouched,
|
||||||
|
required this.hasNames,
|
||||||
|
required this.scheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = switch ((isActive, isTouched, hasNames)) {
|
||||||
|
(true, true, _) => scheme.onPrimary,
|
||||||
|
(true, false, _) => scheme.primary,
|
||||||
|
(false, _, true) => scheme.onSurface,
|
||||||
|
(false, _, false) => scheme.onSurface.withValues(alpha: 0.25),
|
||||||
|
};
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
width: fontSize * 1.8,
|
||||||
|
height: fontSize * 1.8,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isActive && isTouched ? scheme.primary : Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -428,6 +432,23 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
value: _infiniteStock,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _infiniteStock = value);
|
||||||
|
},
|
||||||
|
title: Text(l10n.infiniteStock),
|
||||||
|
subtitle: Text(l10n.infiniteStockDescription),
|
||||||
|
secondary: const Icon(Icons.all_inclusive_rounded),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_infiniteStock) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _stockController,
|
controller: _stockController,
|
||||||
@@ -469,6 +490,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isSaving ? null : _save,
|
onPressed: _isSaving ? null : _save,
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
),
|
),
|
||||||
title: Text(product.name),
|
title: Text(product.name),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
l10n.productStockSummary(
|
product.infiniteStock
|
||||||
|
? l10n.productInfiniteStockSummary(
|
||||||
|
l10n.categoryLabel(product.category),
|
||||||
|
product.priceInCents.asEuro,
|
||||||
|
)
|
||||||
|
: l10n.productStockSummary(
|
||||||
l10n.categoryLabel(product.category),
|
l10n.categoryLabel(product.category),
|
||||||
product.priceInCents.asEuro,
|
product.priceInCents.asEuro,
|
||||||
product.stockQuantity,
|
product.stockQuantity,
|
||||||
|
|||||||
+384
-222
@@ -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: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 32),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(),
|
_Header(vm: _vm),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 32),
|
||||||
_buildProgress(_vm),
|
AnimatedSwitcher(
|
||||||
const SizedBox(height: 24),
|
duration: const Duration(milliseconds: 250),
|
||||||
_buildStatus(_vm),
|
child: switch (_vm.phase) {
|
||||||
const Spacer(),
|
UpdatePhase.downloading ||
|
||||||
_buildVersionInfo(_vm),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kooltap/l10n/app_localizations.dart';
|
||||||
|
import 'package:kooltap/models/product.dart';
|
||||||
|
import 'package:kooltap/views/bar/product_grid.dart';
|
||||||
|
import 'package:kooltap/views/widgets/product_tile.dart';
|
||||||
|
|
||||||
|
final _products = [
|
||||||
|
for (var i = 0; i < 60; i++)
|
||||||
|
Product(
|
||||||
|
id: 'prod-$i',
|
||||||
|
name: 'Product $i',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 10,
|
||||||
|
lowStockThreshold: 2,
|
||||||
|
priceInCents: 300,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> pumpGrid(WidgetTester tester, int rows, Size size) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ProductGrid(
|
||||||
|
products: _products,
|
||||||
|
rows: rows,
|
||||||
|
hasSelectedTab: true,
|
||||||
|
onProductTap: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final size in [const Size(853, 720), const Size(1280, 1000)]) {
|
||||||
|
for (var rows = 2; rows <= 6; rows++) {
|
||||||
|
testWidgets('shows exactly $rows rows at $size', (tester) async {
|
||||||
|
await pumpGrid(tester, rows, size);
|
||||||
|
|
||||||
|
final grid = tester.getRect(find.byType(GridView));
|
||||||
|
final tiles = tester
|
||||||
|
.widgetList(find.byType(ProductTile))
|
||||||
|
.map((tile) => tester.getRect(find.byWidget(tile)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final fullyVisibleRows = {
|
||||||
|
for (final tile in tiles)
|
||||||
|
if (tile.top >= grid.top && tile.bottom <= grid.bottom + 0.5)
|
||||||
|
tile.top.round(),
|
||||||
|
};
|
||||||
|
expect(fullyVisibleRows, hasLength(rows));
|
||||||
|
|
||||||
|
final lastRowBottom = tiles
|
||||||
|
.where((tile) => tile.bottom <= grid.bottom + 0.5)
|
||||||
|
.map((tile) => tile.bottom)
|
||||||
|
.reduce((a, b) => a > b ? a : b);
|
||||||
|
expect(
|
||||||
|
grid.bottom - lastRowBottom,
|
||||||
|
closeTo(16, 1),
|
||||||
|
reason: 'the rows fill the height down to the bottom padding',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,5 +319,64 @@ void main() {
|
|||||||
expect(product!.stockQuantity, 0);
|
expect(product!.stockQuantity, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('infinite stock', () {
|
||||||
|
setUp(() async {
|
||||||
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
|
ProductsCompanion.insert(
|
||||||
|
id: 'water',
|
||||||
|
name: 'Water',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 0,
|
||||||
|
lowStockThreshold: 0,
|
||||||
|
infiniteStock: const Value(true),
|
||||||
|
priceInCents: 100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sells without stock and leaves the count alone', () async {
|
||||||
|
await service.decreaseStock('water', 5);
|
||||||
|
await service.increaseStock('water', 2);
|
||||||
|
|
||||||
|
final product = await service.getProductById('water');
|
||||||
|
expect(product!.stockQuantity, 0);
|
||||||
|
expect(product.infiniteStock, true);
|
||||||
|
expect(product.isOutOfStock, false);
|
||||||
|
expect(product.isLowStock, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still reports a missing product', () async {
|
||||||
|
await expectLater(
|
||||||
|
service.increaseStock('nonexistent', 1),
|
||||||
|
throwsA(isA<ProductNotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is saved by create and update', () async {
|
||||||
|
await service.createProduct(
|
||||||
|
name: 'Tap water',
|
||||||
|
category: 'Drinks',
|
||||||
|
stockQuantity: 0,
|
||||||
|
lowStockThreshold: 0,
|
||||||
|
infiniteStock: true,
|
||||||
|
priceInCents: 0,
|
||||||
|
imagePath: null,
|
||||||
|
);
|
||||||
|
final created = (await service.getProducts()).firstWhere(
|
||||||
|
(product) => product.name == 'Tap water',
|
||||||
|
);
|
||||||
|
expect(created.infiniteStock, true);
|
||||||
|
|
||||||
|
await service.updateProduct(
|
||||||
|
created.copyWith(infiniteStock: false, stockQuantity: 4),
|
||||||
|
);
|
||||||
|
final updated = await service.getProductById(created.id);
|
||||||
|
expect(updated!.infiniteStock, false);
|
||||||
|
expect(updated.stockQuantity, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kooltap/l10n/app_localizations.dart';
|
||||||
|
import 'package:kooltap/models/bar_tab.dart';
|
||||||
|
import 'package:kooltap/utils/name_index.dart';
|
||||||
|
import 'package:kooltap/views/bar/tab_panel.dart';
|
||||||
|
|
||||||
|
const _names = [
|
||||||
|
'Zoë', 'anna', 'Émile', 'Bram', 'Mieke', 'Marc', 'Ward', 'Tom', 'Sofie', //
|
||||||
|
'Lotte', 'Koen', 'Jan', 'Inge', 'Hanne', 'Greet', 'Frank', 'Dirk', 'Chris',
|
||||||
|
'Nele', 'Olaf', 'Pieter', 'Rik', 'Vera', 'Yves', '2de tafel', 'Eva',
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('name index', () {
|
||||||
|
test('folds case and accents into the index letter', () {
|
||||||
|
expect(indexLetterOf('émile'), 'E');
|
||||||
|
expect(indexLetterOf('Zoë'), 'Z');
|
||||||
|
expect(indexLetterOf(' anna'), 'A');
|
||||||
|
expect(indexLetterOf('2de tafel'), '#');
|
||||||
|
expect(indexLetterOf(''), '#');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorts A–Z regardless of case and accents, # last', () {
|
||||||
|
final sorted = ['Zoë', 'émile', 'Eva', 'anna', '2de tafel', 'Bram']
|
||||||
|
..sort(compareByIndex);
|
||||||
|
|
||||||
|
expect(sorted, ['anna', 'Bram', 'émile', 'Eva', 'Zoë', '2de tafel']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TabPanel letter index', () {
|
||||||
|
final tabs = [
|
||||||
|
for (final (index, name) in _names.indexed)
|
||||||
|
BarTab(
|
||||||
|
id: 'tab-$index',
|
||||||
|
customerName: name,
|
||||||
|
openedAt: DateTime(2026, 10, 11, 12, index),
|
||||||
|
items: const [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpPanel(
|
||||||
|
WidgetTester tester, {
|
||||||
|
bool showSearch = false,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(500, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: TabPanel(
|
||||||
|
tabs: tabs,
|
||||||
|
selectedTab: null,
|
||||||
|
selectedTabId: null,
|
||||||
|
stockByProductId: const {},
|
||||||
|
showSearch: showSearch,
|
||||||
|
onTabSelected: (_) {},
|
||||||
|
onItemQuantityChanged: (_, _) async {},
|
||||||
|
onCloseTabPressed: () {},
|
||||||
|
onTabEdited: (_) async {},
|
||||||
|
onTabDeleted: (_) async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String firstVisibleName(WidgetTester tester) {
|
||||||
|
final list = tester.getRect(find.byType(OpenTabsList));
|
||||||
|
final visible = _names.where((name) {
|
||||||
|
final finder = find.text(name);
|
||||||
|
if (finder.evaluate().isEmpty) return false;
|
||||||
|
final rect = tester.getRect(finder);
|
||||||
|
return rect.top >= list.top && rect.bottom <= list.bottom;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return visible.reduce(
|
||||||
|
(a, b) =>
|
||||||
|
tester.getRect(find.text(a)).top < tester.getRect(find.text(b)).top
|
||||||
|
? a
|
||||||
|
: b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> tapLetter(WidgetTester tester, String letter) async {
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byWidgetPredicate(
|
||||||
|
(widget) => widget.runtimeType.toString() == '_LetterIndexBar',
|
||||||
|
),
|
||||||
|
matching: find.text(letter),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('lists the tabs alphabetically', (tester) async {
|
||||||
|
await pumpPanel(tester);
|
||||||
|
|
||||||
|
expect(firstVisibleName(tester), 'anna');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping a letter scrolls to its first name', (tester) async {
|
||||||
|
await pumpPanel(tester);
|
||||||
|
|
||||||
|
await tapLetter(tester, 'M');
|
||||||
|
expect(firstVisibleName(tester), 'Marc');
|
||||||
|
|
||||||
|
await tapLetter(tester, 'E');
|
||||||
|
expect(firstVisibleName(tester), 'Émile');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the search bar only when asked', (tester) async {
|
||||||
|
await pumpPanel(tester);
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
|
||||||
|
await pumpPanel(tester, showSearch: true);
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('closing the search clears the filter', (tester) async {
|
||||||
|
await pumpPanel(tester, showSearch: true);
|
||||||
|
await tester.enterText(find.byType(TextField), 'Marc');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('anna'), findsNothing);
|
||||||
|
|
||||||
|
await pumpPanel(tester);
|
||||||
|
expect(find.text('anna'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a letter without names goes to the next one', (tester) async {
|
||||||
|
await pumpPanel(tester);
|
||||||
|
|
||||||
|
await tapLetter(tester, 'Q');
|
||||||
|
expect(firstVisibleName(tester), 'Rik');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user