feat: add remote products and qr code for payconic

This commit is contained in:
2026-10-22 00:10:26 +02:00
parent b15d94a805
commit 71af6f9ffe
31 changed files with 2053 additions and 179 deletions
+2
View File
@@ -168,6 +168,8 @@ GoRouter createAppRouter({
onSet: adminPinViewModel.setPin,
error: () => adminPinViewModel.error,
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
onClose: () =>
context.canPop() ? context.pop() : context.go('/bar'),
),
),
+30 -1
View File
@@ -36,8 +36,13 @@ class Products extends Table {
IntColumn get priceInCents => integer()();
/// The picture shown on the tile: a local file, also offline.
TextColumn get imagePath => text().nullable()();
/// The server's picture of this product (`/images/<imageId>`); [imagePath]
/// is its downloaded copy once that finished. Null: none on the server.
TextColumn get imageId => text().nullable()();
BoolColumn get active => boolean().withDefault(const Constant(true))();
@override
@@ -227,6 +232,22 @@ class StockAdjustmentResults extends Table {
Set<Column> get primaryKey => {id};
}
/// Pictures taken on this tablet, waiting to be uploaded. Once uploaded, the
/// product points at the server's copy and the row is removed; offline it
/// waits here, while the tile already shows the local file.
@DataClassName('PendingImageUploadRow')
class PendingImageUploads extends Table {
/// One picture per product: a newer one replaces a waiting one.
TextColumn get productId => text()();
TextColumn get localPath => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {productId};
}
@DriftDatabase(
tables: [
Products,
@@ -240,13 +261,14 @@ class StockAdjustmentResults extends Table {
ProcessedRemoteCommands,
TabEvents,
StockAdjustmentResults,
PendingImageUploads,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 17;
int get schemaVersion => 18;
@override
MigrationStrategy get migration {
@@ -362,6 +384,13 @@ class AppDatabase extends _$AppDatabase {
remoteSyncState.settlementsCursor,
);
}
if (from < 18) {
if (!await hasTableColumn('products', 'image_id')) {
await migrator.addColumn(products, products.imageId);
}
await migrator.createTable(pendingImageUploads);
}
},
);
}
+528
View File
@@ -97,6 +97,17 @@ class $ProductsTable extends Products
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _imageIdMeta = const VerificationMeta(
'imageId',
);
@override
late final GeneratedColumn<String> imageId = GeneratedColumn<String>(
'image_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _activeMeta = const VerificationMeta('active');
@override
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
@@ -120,6 +131,7 @@ class $ProductsTable extends Products
infiniteStock,
priceInCents,
imagePath,
imageId,
active,
];
@override
@@ -203,6 +215,12 @@ class $ProductsTable extends Products
imagePath.isAcceptableOrUnknown(data['image_path']!, _imagePathMeta),
);
}
if (data.containsKey('image_id')) {
context.handle(
_imageIdMeta,
imageId.isAcceptableOrUnknown(data['image_id']!, _imageIdMeta),
);
}
if (data.containsKey('active')) {
context.handle(
_activeMeta,
@@ -250,6 +268,10 @@ class $ProductsTable extends Products
DriftSqlType.string,
data['${effectivePrefix}image_path'],
),
imageId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}image_id'],
),
active: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}active'],
@@ -273,7 +295,13 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
/// Products like tap water that are never counted; their stock is ignored.
final bool infiniteStock;
final int priceInCents;
/// The picture shown on the tile: a local file, also offline.
final String? imagePath;
/// The server's picture of this product (`/images/<imageId>`); [imagePath]
/// is its downloaded copy once that finished. Null: none on the server.
final String? imageId;
final bool active;
const ProductRow({
required this.id,
@@ -284,6 +312,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
required this.infiniteStock,
required this.priceInCents,
this.imagePath,
this.imageId,
required this.active,
});
@override
@@ -299,6 +328,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
if (!nullToAbsent || imagePath != null) {
map['image_path'] = Variable<String>(imagePath);
}
if (!nullToAbsent || imageId != null) {
map['image_id'] = Variable<String>(imageId);
}
map['active'] = Variable<bool>(active);
return map;
}
@@ -315,6 +347,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
imagePath: imagePath == null && nullToAbsent
? const Value.absent()
: Value(imagePath),
imageId: imageId == null && nullToAbsent
? const Value.absent()
: Value(imageId),
active: Value(active),
);
}
@@ -333,6 +368,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
infiniteStock: serializer.fromJson<bool>(json['infiniteStock']),
priceInCents: serializer.fromJson<int>(json['priceInCents']),
imagePath: serializer.fromJson<String?>(json['imagePath']),
imageId: serializer.fromJson<String?>(json['imageId']),
active: serializer.fromJson<bool>(json['active']),
);
}
@@ -348,6 +384,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
'infiniteStock': serializer.toJson<bool>(infiniteStock),
'priceInCents': serializer.toJson<int>(priceInCents),
'imagePath': serializer.toJson<String?>(imagePath),
'imageId': serializer.toJson<String?>(imageId),
'active': serializer.toJson<bool>(active),
};
}
@@ -361,6 +398,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
bool? infiniteStock,
int? priceInCents,
Value<String?> imagePath = const Value.absent(),
Value<String?> imageId = const Value.absent(),
bool? active,
}) => ProductRow(
id: id ?? this.id,
@@ -371,6 +409,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
infiniteStock: infiniteStock ?? this.infiniteStock,
priceInCents: priceInCents ?? this.priceInCents,
imagePath: imagePath.present ? imagePath.value : this.imagePath,
imageId: imageId.present ? imageId.value : this.imageId,
active: active ?? this.active,
);
ProductRow copyWithCompanion(ProductsCompanion data) {
@@ -391,6 +430,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
? data.priceInCents.value
: this.priceInCents,
imagePath: data.imagePath.present ? data.imagePath.value : this.imagePath,
imageId: data.imageId.present ? data.imageId.value : this.imageId,
active: data.active.present ? data.active.value : this.active,
);
}
@@ -406,6 +446,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
..write('infiniteStock: $infiniteStock, ')
..write('priceInCents: $priceInCents, ')
..write('imagePath: $imagePath, ')
..write('imageId: $imageId, ')
..write('active: $active')
..write(')'))
.toString();
@@ -421,6 +462,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
infiniteStock,
priceInCents,
imagePath,
imageId,
active,
);
@override
@@ -435,6 +477,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
other.infiniteStock == this.infiniteStock &&
other.priceInCents == this.priceInCents &&
other.imagePath == this.imagePath &&
other.imageId == this.imageId &&
other.active == this.active);
}
@@ -447,6 +490,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
final Value<bool> infiniteStock;
final Value<int> priceInCents;
final Value<String?> imagePath;
final Value<String?> imageId;
final Value<bool> active;
final Value<int> rowid;
const ProductsCompanion({
@@ -458,6 +502,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
this.infiniteStock = const Value.absent(),
this.priceInCents = const Value.absent(),
this.imagePath = const Value.absent(),
this.imageId = const Value.absent(),
this.active = const Value.absent(),
this.rowid = const Value.absent(),
});
@@ -470,6 +515,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
this.infiniteStock = const Value.absent(),
required int priceInCents,
this.imagePath = const Value.absent(),
this.imageId = const Value.absent(),
this.active = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
@@ -487,6 +533,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
Expression<bool>? infiniteStock,
Expression<int>? priceInCents,
Expression<String>? imagePath,
Expression<String>? imageId,
Expression<bool>? active,
Expression<int>? rowid,
}) {
@@ -499,6 +546,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
if (infiniteStock != null) 'infinite_stock': infiniteStock,
if (priceInCents != null) 'price_in_cents': priceInCents,
if (imagePath != null) 'image_path': imagePath,
if (imageId != null) 'image_id': imageId,
if (active != null) 'active': active,
if (rowid != null) 'rowid': rowid,
});
@@ -513,6 +561,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
Value<bool>? infiniteStock,
Value<int>? priceInCents,
Value<String?>? imagePath,
Value<String?>? imageId,
Value<bool>? active,
Value<int>? rowid,
}) {
@@ -525,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
infiniteStock: infiniteStock ?? this.infiniteStock,
priceInCents: priceInCents ?? this.priceInCents,
imagePath: imagePath ?? this.imagePath,
imageId: imageId ?? this.imageId,
active: active ?? this.active,
rowid: rowid ?? this.rowid,
);
@@ -557,6 +607,9 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
if (imagePath.present) {
map['image_path'] = Variable<String>(imagePath.value);
}
if (imageId.present) {
map['image_id'] = Variable<String>(imageId.value);
}
if (active.present) {
map['active'] = Variable<bool>(active.value);
}
@@ -577,6 +630,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
..write('infiniteStock: $infiniteStock, ')
..write('priceInCents: $priceInCents, ')
..write('imagePath: $imagePath, ')
..write('imageId: $imageId, ')
..write('active: $active, ')
..write('rowid: $rowid')
..write(')'))
@@ -4447,6 +4501,278 @@ class StockAdjustmentResultsCompanion
}
}
class $PendingImageUploadsTable extends PendingImageUploads
with TableInfo<$PendingImageUploadsTable, PendingImageUploadRow> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$PendingImageUploadsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _productIdMeta = const VerificationMeta(
'productId',
);
@override
late final GeneratedColumn<String> productId = GeneratedColumn<String>(
'product_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _localPathMeta = const VerificationMeta(
'localPath',
);
@override
late final GeneratedColumn<String> localPath = GeneratedColumn<String>(
'local_path',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [productId, localPath, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'pending_image_uploads';
@override
VerificationContext validateIntegrity(
Insertable<PendingImageUploadRow> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('product_id')) {
context.handle(
_productIdMeta,
productId.isAcceptableOrUnknown(data['product_id']!, _productIdMeta),
);
} else if (isInserting) {
context.missing(_productIdMeta);
}
if (data.containsKey('local_path')) {
context.handle(
_localPathMeta,
localPath.isAcceptableOrUnknown(data['local_path']!, _localPathMeta),
);
} else if (isInserting) {
context.missing(_localPathMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
} else if (isInserting) {
context.missing(_createdAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {productId};
@override
PendingImageUploadRow map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return PendingImageUploadRow(
productId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}product_id'],
)!,
localPath: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}local_path'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
$PendingImageUploadsTable createAlias(String alias) {
return $PendingImageUploadsTable(attachedDatabase, alias);
}
}
class PendingImageUploadRow extends DataClass
implements Insertable<PendingImageUploadRow> {
/// One picture per product: a newer one replaces a waiting one.
final String productId;
final String localPath;
final DateTime createdAt;
const PendingImageUploadRow({
required this.productId,
required this.localPath,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['product_id'] = Variable<String>(productId);
map['local_path'] = Variable<String>(localPath);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
PendingImageUploadsCompanion toCompanion(bool nullToAbsent) {
return PendingImageUploadsCompanion(
productId: Value(productId),
localPath: Value(localPath),
createdAt: Value(createdAt),
);
}
factory PendingImageUploadRow.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return PendingImageUploadRow(
productId: serializer.fromJson<String>(json['productId']),
localPath: serializer.fromJson<String>(json['localPath']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'productId': serializer.toJson<String>(productId),
'localPath': serializer.toJson<String>(localPath),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
PendingImageUploadRow copyWith({
String? productId,
String? localPath,
DateTime? createdAt,
}) => PendingImageUploadRow(
productId: productId ?? this.productId,
localPath: localPath ?? this.localPath,
createdAt: createdAt ?? this.createdAt,
);
PendingImageUploadRow copyWithCompanion(PendingImageUploadsCompanion data) {
return PendingImageUploadRow(
productId: data.productId.present ? data.productId.value : this.productId,
localPath: data.localPath.present ? data.localPath.value : this.localPath,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('PendingImageUploadRow(')
..write('productId: $productId, ')
..write('localPath: $localPath, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(productId, localPath, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is PendingImageUploadRow &&
other.productId == this.productId &&
other.localPath == this.localPath &&
other.createdAt == this.createdAt);
}
class PendingImageUploadsCompanion
extends UpdateCompanion<PendingImageUploadRow> {
final Value<String> productId;
final Value<String> localPath;
final Value<DateTime> createdAt;
final Value<int> rowid;
const PendingImageUploadsCompanion({
this.productId = const Value.absent(),
this.localPath = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
PendingImageUploadsCompanion.insert({
required String productId,
required String localPath,
required DateTime createdAt,
this.rowid = const Value.absent(),
}) : productId = Value(productId),
localPath = Value(localPath),
createdAt = Value(createdAt);
static Insertable<PendingImageUploadRow> custom({
Expression<String>? productId,
Expression<String>? localPath,
Expression<DateTime>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (productId != null) 'product_id': productId,
if (localPath != null) 'local_path': localPath,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
PendingImageUploadsCompanion copyWith({
Value<String>? productId,
Value<String>? localPath,
Value<DateTime>? createdAt,
Value<int>? rowid,
}) {
return PendingImageUploadsCompanion(
productId: productId ?? this.productId,
localPath: localPath ?? this.localPath,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (productId.present) {
map['product_id'] = Variable<String>(productId.value);
}
if (localPath.present) {
map['local_path'] = Variable<String>(localPath.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('PendingImageUploadsCompanion(')
..write('productId: $productId, ')
..write('localPath: $localPath, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -4469,6 +4795,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $TabEventsTable tabEvents = $TabEventsTable(this);
late final $StockAdjustmentResultsTable stockAdjustmentResults =
$StockAdjustmentResultsTable(this);
late final $PendingImageUploadsTable pendingImageUploads =
$PendingImageUploadsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -4485,6 +4813,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
processedRemoteCommands,
tabEvents,
stockAdjustmentResults,
pendingImageUploads,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
@@ -4515,6 +4844,7 @@ typedef $$ProductsTableCreateCompanionBuilder =
Value<bool> infiniteStock,
required int priceInCents,
Value<String?> imagePath,
Value<String?> imageId,
Value<bool> active,
Value<int> rowid,
});
@@ -4528,6 +4858,7 @@ typedef $$ProductsTableUpdateCompanionBuilder =
Value<bool> infiniteStock,
Value<int> priceInCents,
Value<String?> imagePath,
Value<String?> imageId,
Value<bool> active,
Value<int> rowid,
});
@@ -4604,6 +4935,11 @@ class $$ProductsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get imageId => $composableBuilder(
column: $table.imageId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get active => $composableBuilder(
column: $table.active,
builder: (column) => ColumnFilters(column),
@@ -4684,6 +5020,11 @@ class $$ProductsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get imageId => $composableBuilder(
column: $table.imageId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get active => $composableBuilder(
column: $table.active,
builder: (column) => ColumnOrderings(column),
@@ -4731,6 +5072,9 @@ class $$ProductsTableAnnotationComposer
GeneratedColumn<String> get imagePath =>
$composableBuilder(column: $table.imagePath, builder: (column) => column);
GeneratedColumn<String> get imageId =>
$composableBuilder(column: $table.imageId, builder: (column) => column);
GeneratedColumn<bool> get active =>
$composableBuilder(column: $table.active, builder: (column) => column);
@@ -4796,6 +5140,7 @@ class $$ProductsTableTableManager
Value<bool> infiniteStock = const Value.absent(),
Value<int> priceInCents = const Value.absent(),
Value<String?> imagePath = const Value.absent(),
Value<String?> imageId = const Value.absent(),
Value<bool> active = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ProductsCompanion(
@@ -4807,6 +5152,7 @@ class $$ProductsTableTableManager
infiniteStock: infiniteStock,
priceInCents: priceInCents,
imagePath: imagePath,
imageId: imageId,
active: active,
rowid: rowid,
),
@@ -4820,6 +5166,7 @@ class $$ProductsTableTableManager
Value<bool> infiniteStock = const Value.absent(),
required int priceInCents,
Value<String?> imagePath = const Value.absent(),
Value<String?> imageId = const Value.absent(),
Value<bool> active = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ProductsCompanion.insert(
@@ -4831,6 +5178,7 @@ class $$ProductsTableTableManager
infiniteStock: infiniteStock,
priceInCents: priceInCents,
imagePath: imagePath,
imageId: imageId,
active: active,
rowid: rowid,
),
@@ -7549,6 +7897,184 @@ typedef $$StockAdjustmentResultsTableProcessedTableManager =
StockAdjustmentResultRow,
PrefetchHooks Function()
>;
typedef $$PendingImageUploadsTableCreateCompanionBuilder =
PendingImageUploadsCompanion Function({
required String productId,
required String localPath,
required DateTime createdAt,
Value<int> rowid,
});
typedef $$PendingImageUploadsTableUpdateCompanionBuilder =
PendingImageUploadsCompanion Function({
Value<String> productId,
Value<String> localPath,
Value<DateTime> createdAt,
Value<int> rowid,
});
class $$PendingImageUploadsTableFilterComposer
extends Composer<_$AppDatabase, $PendingImageUploadsTable> {
$$PendingImageUploadsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get productId => $composableBuilder(
column: $table.productId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get localPath => $composableBuilder(
column: $table.localPath,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
}
class $$PendingImageUploadsTableOrderingComposer
extends Composer<_$AppDatabase, $PendingImageUploadsTable> {
$$PendingImageUploadsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get productId => $composableBuilder(
column: $table.productId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get localPath => $composableBuilder(
column: $table.localPath,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$PendingImageUploadsTableAnnotationComposer
extends Composer<_$AppDatabase, $PendingImageUploadsTable> {
$$PendingImageUploadsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get productId =>
$composableBuilder(column: $table.productId, builder: (column) => column);
GeneratedColumn<String> get localPath =>
$composableBuilder(column: $table.localPath, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$PendingImageUploadsTableTableManager
extends
RootTableManager<
_$AppDatabase,
$PendingImageUploadsTable,
PendingImageUploadRow,
$$PendingImageUploadsTableFilterComposer,
$$PendingImageUploadsTableOrderingComposer,
$$PendingImageUploadsTableAnnotationComposer,
$$PendingImageUploadsTableCreateCompanionBuilder,
$$PendingImageUploadsTableUpdateCompanionBuilder,
(
PendingImageUploadRow,
BaseReferences<
_$AppDatabase,
$PendingImageUploadsTable,
PendingImageUploadRow
>,
),
PendingImageUploadRow,
PrefetchHooks Function()
> {
$$PendingImageUploadsTableTableManager(
_$AppDatabase db,
$PendingImageUploadsTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$PendingImageUploadsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$PendingImageUploadsTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
$$PendingImageUploadsTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
Value<String> productId = const Value.absent(),
Value<String> localPath = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PendingImageUploadsCompanion(
productId: productId,
localPath: localPath,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback:
({
required String productId,
required String localPath,
required DateTime createdAt,
Value<int> rowid = const Value.absent(),
}) => PendingImageUploadsCompanion.insert(
productId: productId,
localPath: localPath,
createdAt: createdAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$PendingImageUploadsTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$PendingImageUploadsTable,
PendingImageUploadRow,
$$PendingImageUploadsTableFilterComposer,
$$PendingImageUploadsTableOrderingComposer,
$$PendingImageUploadsTableAnnotationComposer,
$$PendingImageUploadsTableCreateCompanionBuilder,
$$PendingImageUploadsTableUpdateCompanionBuilder,
(
PendingImageUploadRow,
BaseReferences<
_$AppDatabase,
$PendingImageUploadsTable,
PendingImageUploadRow
>,
),
PendingImageUploadRow,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -7581,4 +8107,6 @@ class $AppDatabaseManager {
_db,
_db.stockAdjustmentResults,
);
$$PendingImageUploadsTableTableManager get pendingImageUploads =>
$$PendingImageUploadsTableTableManager(_db, _db.pendingImageUploads);
}
+162 -51
View File
@@ -1,73 +1,184 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../models/remote_command.dart';
import '../models/tab_event.dart';
import 'app_database.dart';
/// The product catalog as the website manages it.
/// A product as the catalog and this tablet's unsent changes make it.
class _Product {
String name;
String category;
int priceInCents;
int lowStockThreshold;
bool active;
String? imageId;
String? imagePath;
_Product(ProductRow row)
: name = row.name,
category = row.category,
priceInCents = row.priceInCents,
lowStockThreshold = row.lowStockThreshold,
active = row.active,
imageId = row.imageId,
imagePath = row.imagePath;
_Product.fromServer(ServerProduct product)
: name = product.name,
category = product.category ?? '',
priceInCents = product.priceInCents,
lowStockThreshold = product.lowStockThreshold ?? 0,
active = !product.archived,
imageId = product.imageId,
imagePath = null;
bool sameAs(ProductRow row) =>
name == row.name &&
category == row.category &&
priceInCents == row.priceInCents &&
lowStockThreshold == row.lowStockThreshold &&
active == row.active &&
imageId == row.imageId &&
imagePath == row.imagePath;
ProductsCompanion toCompanion() => ProductsCompanion(
name: Value(name),
category: Value(category),
priceInCents: Value(priceInCents),
lowStockThreshold: Value(lowStockThreshold),
active: Value(active),
imageId: Value(imageId),
imagePath: Value(imagePath),
);
/// Applies the fields of a `product_created` / `product_updated` event.
void apply(Map<String, dynamic> fields) {
if (fields['name'] case final String value) name = value;
if (fields.containsKey('category')) {
category = fields['category'] as String? ?? '';
}
if (fields['priceInCents'] case final int value) priceInCents = value;
if (fields.containsKey('lowStockThreshold')) {
lowStockThreshold = fields['lowStockThreshold'] as int? ?? 0;
}
if (fields.containsKey('imageId')) imageId = fields['imageId'] as String?;
}
}
/// The product catalog: the website's, with this tablet's changes on top.
extension CatalogQueries on AppDatabase {
/// Makes the local products match [catalog]: new products are added,
/// changed ones updated, archived ones and products the server doesn't
/// know (such as the default products of a new tablet) are hidden. Hidden,
/// not deleted: open tabs and the history refer to them.
/// Makes the local products match [catalog], then replays this tablet's
/// product changes the server hasn't applied yet on top, so an offline
/// edit stays visible until the server has it (the same idea as the tabs).
///
/// The stock is not touched here: the stock rebase that follows sets it, a
/// new product starts as untracked until then. Local images are kept.
/// New products are added; archived ones, and products the server doesn't
/// know and this tablet didn't create (such as the default products of a
/// new tablet), are hidden: open tabs and the history refer to them.
///
/// Pictures: a changed one keeps showing the old file until
/// [ProductImageSync] downloaded the new one; a picture waiting to be
/// uploaded from this tablet is left alone. The stock is not touched here:
/// the stock rebase that follows sets it.
Future<void> applyServerCatalog(List<ServerProduct> catalog) {
return transaction(() async {
final local = {
final rows = {
for (final row in await select(products).get()) row.id: row,
};
final uploading = {
for (final row in await select(pendingImageUploads).get())
row.productId,
};
final desired = {for (final row in rows.values) row.id: _Product(row)};
final listed = <String>{};
for (final product in catalog) {
final category = product.category ?? '';
final lowStockThreshold = product.lowStockThreshold ?? 0;
final existing = local.remove(product.id);
if (existing == null) {
await into(products).insert(
ProductsCompanion.insert(
id: product.id,
name: product.name,
category: category,
stockQuantity: 0,
lowStockThreshold: lowStockThreshold,
infiniteStock: const Value(true),
priceInCents: product.priceInCents,
active: Value(!product.archived),
),
);
listed.add(product.id);
final local = desired[product.id];
final next = _Product.fromServer(product);
if (local == null) {
desired[product.id] = next;
continue;
}
if (existing.name == product.name &&
existing.category == category &&
existing.priceInCents == product.priceInCents &&
existing.lowStockThreshold == lowStockThreshold &&
existing.active == !product.archived) {
continue;
if (uploading.contains(product.id)) {
next
..imageId = local.imageId
..imagePath = local.imagePath;
} else if (next.imageId != null && next.imageId == local.imageId) {
next.imagePath = local.imagePath;
} else if (next.imageId != null) {
// Until the new picture is downloaded, the old one is better than none
next.imagePath = local.imagePath;
}
await (update(
products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
name: Value(product.name),
category: Value(category),
priceInCents: Value(product.priceInCents),
lowStockThreshold: Value(lowStockThreshold),
active: Value(!product.archived),
),
);
desired[product.id] = next;
}
for (final entry in desired.entries) {
if (!listed.contains(entry.key)) entry.value.active = false;
}
final unknown = [
for (final row in local.values)
if (row.active) row.id,
];
if (unknown.isNotEmpty) {
await (update(products)..where((row) => row.id.isIn(unknown))).write(
const ProductsCompanion(active: Value(false)),
);
for (final event in await _pendingProductEvents()) {
final productId = event['productId'] as String?;
final local = productId == null ? null : desired[productId];
if (local == null) continue;
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
case TabEventType.productCreated || TabEventType.productUpdated:
local.apply(event['product'] as Map<String, dynamic>? ?? {});
if (event['type'] == TabEventType.productCreated.value) {
local.active = true;
}
case TabEventType.productArchived:
local.active = false;
default:
break;
}
}
for (final MapEntry(key: id, value: product) in desired.entries) {
final row = rows[id];
if (row == null) {
await into(products).insert(
product.toCompanion().copyWith(
id: Value(id),
// Untracked until the stock rebase sets its stock
stockQuantity: const Value(0),
infiniteStock: const Value(true),
),
);
} else if (!product.sameAs(row)) {
await (update(
products,
)..where((r) => r.id.equals(id))).write(product.toCompanion());
}
}
});
}
/// Queues a picture taken on this tablet for upload; a newer picture of the
/// same product replaces one still waiting.
Future<void> queueImageUpload(String productId, String localPath) async {
await into(pendingImageUploads).insertOnConflictUpdate(
PendingImageUploadsCompanion.insert(
productId: productId,
localPath: localPath,
createdAt: DateTime.now(),
),
);
}
Future<List<Map<String, dynamic>>> _pendingProductEvents() async {
final types = TabEventType.products.map((type) => type.value).toSet();
final rows =
await (select(tabEvents)..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
]))
.get();
return [
for (final row in rows)
if (jsonDecode(row.payload) case final Map<String, dynamic> event
when types.contains(event['type']))
event,
];
}
}
+4 -4
View File
@@ -50,10 +50,10 @@ extension StockQueries on AppDatabase {
);
}
/// Queues a `stock_set` event, so a stock count or tracking change made on
/// this tablet reaches the server. Call it inside the transaction that
/// writes the stock.
Future<void> queueStockSet(TabEvent event) async {
/// Queues an event that is not about a tab (a stock count, a product
/// change) in the outbox. Call it inside the transaction that makes the
/// change, so the change and its event are stored together.
Future<void> queueEvent(TabEvent event) async {
await into(tabEvents).insert(
TabEventsCompanion.insert(
id: event.id,
+7 -2
View File
@@ -4,7 +4,6 @@
"barTabs": "Bar Tabs",
"products": "Products",
"addProduct": "Add product",
"productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.",
"noProductsYet": "No products yet.",
"addFirstProduct": "Add your first product to start selling.",
"manageProducts": "Manage products",
@@ -319,5 +318,11 @@
"registeringDevice": "Registering device…",
"deviceRegistered": "Device registered!",
"openTabsWillSync": "Open tabs will sync to the dashboard.",
"done": "Done"
"done": "Done",
"scanToPay": "Let the customer scan this code to pay {amount}",
"@scanToPay": {
"placeholders": {
"amount": {"type": "String"}
}
}
}
+6 -6
View File
@@ -122,12 +122,6 @@ abstract class AppLocalizations {
/// **'Add product'**
String get addProduct;
/// No description provided for @productsManagedOnWebsite.
///
/// In en, this message translates to:
/// **'Products are managed on the website. Here you can change the stock and the picture.'**
String get productsManagedOnWebsite;
/// No description provided for @noProductsYet.
///
/// In en, this message translates to:
@@ -1885,6 +1879,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Done'**
String get done;
/// No description provided for @scanToPay.
///
/// In en, this message translates to:
/// **'Let the customer scan this code to pay {amount}'**
String scanToPay(String amount);
}
class _AppLocalizationsDelegate
+5 -4
View File
@@ -20,10 +20,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addProduct => 'Add product';
@override
String get productsManagedOnWebsite =>
'Products are managed on the website. Here you can change the stock and the picture.';
@override
String get noProductsYet => 'No products yet.';
@@ -980,4 +976,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get done => 'Done';
@override
String scanToPay(String amount) {
return 'Let the customer scan this code to pay $amount';
}
}
+5 -4
View File
@@ -20,10 +20,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get addProduct => 'Product toevoegen';
@override
String get productsManagedOnWebsite =>
'Producten beheer je op de website. Hier pas je de voorraad en de foto aan.';
@override
String get noProductsYet => 'Nog geen producten.';
@@ -993,4 +989,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get done => 'Klaar';
@override
String scanToPay(String amount) {
return 'Laat de klant deze code scannen om $amount te betalen';
}
}
+2 -2
View File
@@ -4,7 +4,6 @@
"barTabs": "Poefs",
"products": "Producten",
"addProduct": "Product toevoegen",
"productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.",
"noProductsYet": "Nog geen producten.",
"addFirstProduct": "Voeg je eerste product toe om te beginnen.",
"manageProducts": "Producten beheren",
@@ -318,5 +317,6 @@
"registeringDevice": "Toestel registreren…",
"deviceRegistered": "Toestel geregistreerd!",
"openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.",
"done": "Klaar"
"done": "Klaar",
"scanToPay": "Laat de klant deze code scannen om {amount} te betalen"
}
+15
View File
@@ -1,8 +1,12 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@@ -14,6 +18,7 @@ import 'services/admin_pin_service.dart';
import 'services/bar_tab_service.dart';
import 'services/kooltap_api_client.dart';
import 'services/pin_lock_service.dart';
import 'services/product_image_sync.dart';
import 'services/product_service.dart';
import 'services/remote_sync_service.dart';
import 'services/settings_service.dart';
@@ -70,6 +75,16 @@ Future<void> _runApp() async {
database: database,
barTabService: barTabService,
apiClient: apiClient,
imageSync: ProductImageSync(
database: database,
apiClient: apiClient,
imagesDirectory: () async => Directory(
path.join(
(await getApplicationSupportDirectory()).path,
'product_images',
),
),
),
);
final pinLockViewModel = PinLockViewModel(
+5
View File
@@ -170,6 +170,9 @@ class ServerProduct {
/// Deleted on the website: hidden, kept for open tabs and the history.
final bool archived;
/// The picture, at `/images/<imageId>`; null: none.
final String? imageId;
const ServerProduct({
required this.id,
required this.name,
@@ -177,6 +180,7 @@ class ServerProduct {
required this.priceInCents,
this.lowStockThreshold,
this.archived = false,
this.imageId,
});
factory ServerProduct.fromJson(Map<String, dynamic> json) {
@@ -187,6 +191,7 @@ class ServerProduct {
priceInCents: json['priceInCents'] as int,
lowStockThreshold: json['lowStockThreshold'] as int?,
archived: json['archived'] as bool? ?? false,
imageId: json['imageId'] as String?,
);
}
}
+51 -1
View File
@@ -13,7 +13,16 @@ enum TabEventType {
/// A stock count or tracking change made on the tablet. Not about a tab:
/// it has no `tabId`.
stockSet('stock_set');
stockSet('stock_set'),
/// A product created, changed or deleted (archived) on the tablet. Not
/// about a tab either.
productCreated('product_created'),
productUpdated('product_updated'),
productArchived('product_archived');
/// The product events, which the catalog replays instead of the tabs.
static const products = {productCreated, productUpdated, productArchived};
final String value;
@@ -177,6 +186,47 @@ class TabEventFactory {
);
}
/// A product created on this tablet: [fields] holds `name`, `category`
/// (null: none), `priceInCents` and `lowStockThreshold`. The id is the
/// tablet's, so the product can be sold before the server knows it.
TabEvent productCreated({
required String productId,
required Map<String, Object?> fields,
}) {
return _productEvent(TabEventType.productCreated, productId, fields);
}
/// Only the [fields] that changed; `imageId` is a picture uploaded first.
TabEvent productUpdated({
required String productId,
required Map<String, Object?> fields,
}) {
return _productEvent(TabEventType.productUpdated, productId, fields);
}
TabEvent productArchived({required String productId}) {
return _productEvent(TabEventType.productArchived, productId, null);
}
TabEvent _productEvent(
TabEventType type,
String productId,
Map<String, Object?>? fields,
) {
final id = _uuid.v4();
return TabEvent(
id: id,
payload: {
'id': id,
'type': type.value,
'productId': productId,
'product': ?fields,
'occurredAt': _now().toUtc().toIso8601String(),
},
);
}
/// The server's payment method names: `cash`, `payconiq`,
/// `payment_pending` or `other`.
static String paymentMethodValue(PaymentMethod method) {
+5 -1
View File
@@ -892,8 +892,12 @@ class DriftBarTabService implements BarTabService {
for (final item in event['items'] as List<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>);
}
// Stock is rebased separately, see StockQueries.rebaseStock
// Stock and products are rebased separately, see StockQueries and
// CatalogQueries
case TabEventType.stockSet:
case TabEventType.productCreated:
case TabEventType.productUpdated:
case TabEventType.productArchived:
case null:
return;
}
+1 -1
View File
@@ -71,7 +71,7 @@ class DefaultProductSeeder {
imagePath: Value(imagePath),
),
);
await database.queueStockSet(
await database.queueEvent(
events.stockSet(productId: id, quantity: stock),
);
}
+37
View File
@@ -104,6 +104,43 @@ class KoolTapApiClient {
);
}
/// Uploads a picture taken on this tablet; the server crops and stores it
/// and returns its id, which a `product_updated` event then links.
Future<String> uploadImage({
required String token,
required String deviceId,
required List<int> bytes,
required String filename,
}) async {
final request =
http.MultipartRequest(
'POST',
Uri.parse('$baseUrl/devices/$deviceId/images'),
)
..headers['Authorization'] = 'Bearer $token'
..files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
// Uploading a photo over a slow connection takes longer than a sync
final response = await http.Response.fromStream(
await _client.send(request).timeout(_timeout * 4),
);
final data = _decode(response, authenticated: true) as Map<String, dynamic>;
return data['imageId'] as String;
}
/// A product picture (public: pictures never change at their address).
Future<List<int>> downloadImage(String imageId) async {
final response = await _client
.get(Uri.parse('$baseUrl/images/$imageId'))
.timeout(_timeout * 2);
if (response.statusCode != 200) {
throw ApiException(response.statusCode, 'Image $imageId not available');
}
return response.bodyBytes;
}
Future<Object?> _post(
String path,
Map<String, dynamic> body, {
+180
View File
@@ -0,0 +1,180 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import '../database/app_database.dart';
import '../database/stock_queries.dart';
import '../models/tab_event.dart';
import 'kooltap_api_client.dart';
/// Moves product pictures between this tablet and the server. Pictures are
/// kept as local files, so the tiles show them offline.
///
/// - A picture taken here waits in [PendingImageUploads]; once uploaded, a
/// `product_updated` event links it to its product for every tablet.
/// - A picture set elsewhere (the website, another tablet) is downloaded in
/// the background after a sync, as `<imageId>.webp`; until then the tile
/// keeps showing the previous picture.
/// - Files no product uses anymore are deleted.
class ProductImageSync {
final AppDatabase database;
final KoolTapApiClient apiClient;
final TabEventFactory eventFactory;
/// Where the pictures are kept: the folder the product form copies picked
/// pictures into as well.
final Future<Directory> Function() imagesDirectory;
/// A file nobody refers to is only deleted after this long: the product
/// form copies a picked picture here before the product is saved.
final Duration unusedFileAge;
ProductImageSync({
required this.database,
required this.apiClient,
required this.imagesDirectory,
TabEventFactory? eventFactory,
this.unusedFileAge = const Duration(hours: 1),
}) : eventFactory = eventFactory ?? TabEventFactory();
Future<void>? _downloading;
/// Uploads the waiting pictures, oldest first. Throws at the first upload
/// that fails for a reason that may pass (offline): the rest waits for the
/// next sync. A picture the server can't read is dropped instead of
/// blocking the queue forever.
Future<void> uploadPending({
required String token,
required String deviceId,
}) async {
final waiting = await (database.select(
database.pendingImageUploads,
)..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
for (final upload in waiting) {
final file = File(upload.localPath);
if (!await file.exists()) {
await _forget(upload);
continue;
}
final String imageId;
try {
imageId = await apiClient.uploadImage(
token: token,
deviceId: deviceId,
bytes: await file.readAsBytes(),
filename: path.basename(upload.localPath),
);
} on ApiException catch (e) {
if (e.statusCode != 400 && e.statusCode != 413) rethrow;
debugPrint('ProductImageSync: picture refused: $e');
await _forget(upload);
continue;
}
await database.transaction(() async {
// Replaced by a newer picture while uploading: that one goes next
final current =
await (database.select(database.pendingImageUploads)
..where((row) => row.productId.equals(upload.productId)))
.getSingleOrNull();
if (current?.localPath != upload.localPath) return;
await database.queueEvent(
eventFactory.productUpdated(
productId: upload.productId,
fields: {'imageId': imageId},
),
);
await (database.update(database.products)
..where((row) => row.id.equals(upload.productId)))
.write(ProductsCompanion(imageId: Value(imageId)));
await _forget(upload);
});
}
}
/// Downloads the pictures this tablet doesn't have yet and points their
/// products at them, then deletes files no product uses. A picture that
/// fails (offline) keeps the old one and is tried again after the next
/// sync. Runs once at a time: a call during a run waits for that run.
Future<void> downloadMissing() {
return _downloading ??= _download().whenComplete(() => _downloading = null);
}
Future<void> _download() async {
final directory = await imagesDirectory();
await directory.create(recursive: true);
final uploading = {
for (final row
in await database.select(database.pendingImageUploads).get())
row.productId,
};
final products = await (database.select(
database.products,
)..where((row) => row.imageId.isNotNull())).get();
for (final product in products) {
if (uploading.contains(product.id)) continue;
final imageId = product.imageId!;
final target = path.join(directory.path, '$imageId.webp');
if (product.imagePath == target && await File(target).exists()) {
continue;
}
try {
if (!await File(target).exists()) {
final bytes = await apiClient.downloadImage(imageId);
// Written aside first: a tile never shows half a picture
final partial = File('$target.part');
await partial.writeAsBytes(bytes, flush: true);
await partial.rename(target);
}
} catch (e) {
debugPrint('ProductImageSync: picture $imageId not downloaded: $e');
continue;
}
// Unless the product got another picture meanwhile
await (database.update(database.products)..where(
(row) => row.id.equals(product.id) & row.imageId.equals(imageId),
))
.write(ProductsCompanion(imagePath: Value(target)));
}
await _deleteUnused(directory);
}
Future<void> _forget(PendingImageUploadRow upload) async {
await (database.delete(database.pendingImageUploads)..where(
(row) =>
row.productId.equals(upload.productId) &
row.localPath.equals(upload.localPath),
))
.go();
}
Future<void> _deleteUnused(Directory directory) async {
final used = {
for (final row in await database.select(database.products).get())
?row.imagePath,
for (final row
in await database.select(database.pendingImageUploads).get())
row.localPath,
};
final cutoff = DateTime.now().subtract(unusedFileAge);
await for (final entity in directory.list()) {
if (entity is! File || used.contains(entity.path)) continue;
if ((await entity.lastModified()).isAfter(cutoff)) continue;
try {
await entity.delete();
} on FileSystemException catch (e) {
debugPrint('ProductImageSync: could not delete ${entity.path}: $e');
}
}
}
}
+52 -7
View File
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
import '../database/catalog_queries.dart';
import '../database/stock_queries.dart';
import '../models/product.dart';
import '../models/tab_event.dart';
@@ -87,6 +88,8 @@ class DriftProductService implements ProductService {
return _mapRowToProduct(row);
}
/// Queues the new product for the server (which takes this tablet's id), its
/// starting stock as a count, and its picture for upload.
@override
Future<void> createProduct({
required String name,
@@ -114,16 +117,29 @@ class DriftProductService implements ProductService {
imagePath: Value(imagePath),
),
);
await database.queueEvent(
eventFactory.productCreated(
productId: id,
fields: {
'name': name,
'category': _serverCategory(category),
'priceInCents': priceInCents,
'lowStockThreshold': lowStockThreshold,
},
),
);
// The server starts a product it doesn't know as untracked
if (!infiniteStock) {
await database.queueStockSet(
await database.queueEvent(
eventFactory.stockSet(productId: id, quantity: stockQuantity),
);
}
if (imagePath != null) await database.queueImageUpload(id, imagePath);
});
}
/// A changed stock or tracking is queued for the server as a count.
/// Queues what changed for the server: the changed fields, a changed stock
/// or tracking as a count, and a new picture for upload.
@override
Future<void> updateProduct(Product product) async {
await database.transaction(() async {
@@ -147,28 +163,57 @@ class DriftProductService implements ProductService {
);
if (current == null) return;
final fields = <String, Object?>{
if (current.name != product.name) 'name': product.name,
if (current.category != product.category)
'category': _serverCategory(product.category),
if (current.priceInCents != product.priceInCents)
'priceInCents': product.priceInCents,
if (current.lowStockThreshold != product.lowStockThreshold)
'lowStockThreshold': product.lowStockThreshold,
};
if (fields.isNotEmpty) {
await database.queueEvent(
eventFactory.productUpdated(productId: product.id, fields: fields),
);
}
final stockChanged = product.infiniteStock
? !current.infiniteStock
: current.infiniteStock ||
current.stockQuantity != product.stockQuantity;
if (stockChanged) {
await database.queueStockSet(
await database.queueEvent(
eventFactory.stockSet(
productId: product.id,
quantity: product.infiniteStock ? null : product.stockQuantity,
),
);
}
final picture = product.imagePath;
if (picture != null && picture != current.imagePath) {
await database.queueImageUpload(product.id, picture);
}
});
}
/// Only hidden, since open tabs and history still refer to it.
/// Only hidden, since open tabs and history still refer to it; the server
/// archives it for every tablet.
@override
Future<void> deleteProduct(String id) async {
final query = database.update(database.products)
..where((product) => product.id.equals(id));
await database.transaction(() async {
await (database.update(database.products)
..where((product) => product.id.equals(id)))
.write(const ProductsCompanion(active: Value(false)));
await database.queueEvent(eventFactory.productArchived(productId: id));
});
}
await query.write(const ProductsCompanion(active: Value(false)));
/// The server has no category for an empty one.
static String? _serverCategory(String category) {
final trimmed = category.trim();
return trimmed.isEmpty ? null : trimmed;
}
@override
+29
View File
@@ -17,6 +17,7 @@ import '../models/remote_command.dart';
import '../utils/app_config.dart';
import 'bar_tab_service.dart';
import 'kooltap_api_client.dart';
import 'product_image_sync.dart';
typedef _Credentials = ({String deviceId, String token});
@@ -38,6 +39,10 @@ class RemoteSyncService {
final KoolTapApiClient apiClient;
final FlutterSecureStorage secureStorage;
/// Uploads pictures taken here before a sync and downloads new ones after;
/// null leaves pictures alone.
final ProductImageSync? imageSync;
/// How long tab changes must be quiet before they are synced.
final Duration changeDebounce;
@@ -49,6 +54,7 @@ class RemoteSyncService {
required this.barTabService,
required this.apiClient,
FlutterSecureStorage? secureStorage,
this.imageSync,
this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs),
this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs),
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
@@ -222,6 +228,14 @@ class RemoteSyncService {
try {
final appliedStock = await _doSync(credentials);
_consecutiveFailures = 0;
// In the background: a slow download must not hold up the next sync
if (imageSync case final images?) {
unawaited(
images.downloadMissing().catchError(
(Object e) => debugPrint('RemoteSyncService: pictures: $e'),
),
);
}
// Show the new stock on the dashboard within seconds. The follow-up
// skips the adjustments it already applied, so this doesn't loop.
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
@@ -278,6 +292,21 @@ class RemoteSyncService {
/// Returns whether any stock adjustment was applied, which only happens
/// with a server that doesn't own the stock.
Future<bool> _doSync(_Credentials credentials) async {
// Pictures taken here first, so the events linking them go in this sync.
// One that doesn't upload (offline) waits; the sync itself still goes.
if (imageSync case final images?) {
try {
await images.uploadPending(
token: credentials.token,
deviceId: credentials.deviceId,
);
} on UnauthorizedException {
rethrow;
} catch (e) {
debugPrint('RemoteSyncService: picture upload failed: $e');
}
}
final state = await _getSyncState();
// One read, so the snapshot and the stock match the events that are sent
final (outbox, openTabs, products, stockResults) = await database
+56 -6
View File
@@ -64,7 +64,12 @@ Future<void> confirmCloseTab(BuildContext context) async {
},
);
if (confirmed != true) return;
if (confirmed != true || !context.mounted) return;
if (paymentMethod == PaymentMethod.payconiq) {
final paid = await _showPayconiqQr(context, tab.totalInCents.asEuro);
if (paid != true || !context.mounted) return;
}
try {
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
@@ -76,17 +81,62 @@ Future<void> confirmCloseTab(BuildContext context) async {
}
}
const _payconiqQrAsset = 'assets/payconiq/qr.png';
/// Shows the Payconiq QR code for the customer to scan. Resolves to true
/// once the bartender taps Done; dismissing it leaves the tab open.
Future<bool?> _showPayconiqQr(BuildContext context, String amount) {
final l10n = AppLocalizations.of(context);
return showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(l10n.payconiq),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.scanToPay(amount), textAlign: TextAlign.center),
const SizedBox(height: 20),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
_payconiqQrAsset,
width: 280,
height: 280,
fit: BoxFit.contain,
errorBuilder: (context, _, _) => const SizedBox(
width: 280,
height: 280,
child: Icon(Icons.qr_code_2, size: 160),
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.done),
),
),
],
),
),
);
},
);
}
class _PaymentPicker extends StatelessWidget {
final PaymentMethod selected;
final ValueChanged<PaymentMethod> onChanged;
const _PaymentPicker({required this.selected, required this.onChanged});
static const _options = [
PaymentMethod.cash,
PaymentMethod.payconiq,
PaymentMethod.paymentPending,
];
static const _options = [PaymentMethod.cash, PaymentMethod.payconiq];
@override
Widget build(BuildContext context) {
+13 -14
View File
@@ -20,7 +20,8 @@ class PinEntryView extends StatefulWidget {
final Future<bool> Function(String)? onVerify;
final Future<bool> Function(String)? onSet;
final AppError? Function()? error;
final bool showBack;
/// Shows a close button that leaves the screen without entering a PIN.
final VoidCallback? onClose;
const PinEntryView({
super.key,
@@ -30,7 +31,7 @@ class PinEntryView extends StatefulWidget {
this.onVerify,
this.onSet,
this.error,
this.showBack = false,
this.onClose,
});
@override
@@ -185,18 +186,16 @@ class _PinEntryViewState extends State<PinEntryView> {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
if (widget.showBack) ...[
const Spacer(flex: 1),
Row(
children: [
IconButton(
onPressed: () => {},
icon: const Icon(Icons.close),
tooltip: 'Back',
),
],
),
],
Align(
alignment: Alignment.centerLeft,
child: widget.onClose != null
? IconButton(
onPressed: _isSubmitting ? null : widget.onClose,
icon: const Icon(Icons.close),
tooltip: l10n.close,
)
: const SizedBox(height: 48),
),
const Spacer(flex: 2),
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
const SizedBox(height: 16),
+1 -14
View File
@@ -12,8 +12,6 @@ import '../models/product.dart';
import '../utils/money.dart';
import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import 'widgets/managed_on_website_notice.dart';
class ProductFormView extends StatefulWidget {
final String? productId;
@@ -315,9 +313,6 @@ class _ProductFormViewState extends State<ProductFormView> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final title = widget.isEditing ? l10n.editProduct : l10n.addProduct;
// A paired tablet gets its products from the website: only the stock and
// the picture are changed here
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
return Scaffold(
appBar: AppBar(
@@ -327,7 +322,7 @@ class _ProductFormViewState extends State<ProductFormView> {
onPressed: _leaveProductForm,
),
actions: [
if (widget.isEditing && !managedOnWebsite)
if (widget.isEditing)
IconButton(
onPressed: _delete,
icon: const Icon(Icons.delete_outline),
@@ -347,15 +342,10 @@ class _ProductFormViewState extends State<ProductFormView> {
ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(24),
children: [
if (managedOnWebsite) ...[
const ManagedOnWebsiteNotice(),
const SizedBox(height: 16),
],
_buildImagePicker(context),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
enabled: !managedOnWebsite,
decoration: InputDecoration(
labelText: l10n.productName,
border: OutlineInputBorder(),
@@ -376,7 +366,6 @@ class _ProductFormViewState extends State<ProductFormView> {
builder: (context, constraints) {
return DropdownMenu<String>(
width: constraints.maxWidth,
enabled: !managedOnWebsite,
initialSelection: _selectedCategory,
enableFilter: true,
enableSearch: true,
@@ -406,7 +395,6 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16),
TextFormField(
controller: _priceController,
enabled: !managedOnWebsite,
decoration: InputDecoration(
labelText: l10n.price,
prefixText: '€ ',
@@ -467,7 +455,6 @@ class _ProductFormViewState extends State<ProductFormView> {
const SizedBox(height: 16),
TextFormField(
controller: _lowStockController,
enabled: !managedOnWebsite,
decoration: InputDecoration(
labelText: l10n.lowStockThreshold,
border: OutlineInputBorder(),
+8 -17
View File
@@ -9,8 +9,6 @@ import '../l10n/app_localizations_helpers.dart';
import '../utils/money.dart';
import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import 'widgets/managed_on_website_notice.dart';
class ProductListView extends StatefulWidget {
const ProductListView({super.key});
@@ -33,8 +31,6 @@ class _ProductListViewState extends State<ProductListView> {
Widget build(BuildContext context) {
final viewModel = context.watch<ProductListViewModel>();
final l10n = AppLocalizations.of(context);
// A paired tablet gets its products from the website
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
return Scaffold(
appBar: AppBar(
@@ -45,13 +41,11 @@ class _ProductListViewState extends State<ProductListView> {
title: Text(l10n.products),
),
resizeToAvoidBottomInset: false,
floatingActionButton: managedOnWebsite
? null
: FloatingActionButton.extended(
onPressed: () => context.push('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
),
body: Builder(
builder: (context) {
if (viewModel.isLoading) {
@@ -62,19 +56,16 @@ class _ProductListViewState extends State<ProductListView> {
return Center(child: Text(l10n.errorText(viewModel.loadError)));
}
if (viewModel.products.isEmpty && !managedOnWebsite) {
if (viewModel.products.isEmpty) {
return Center(child: Text(l10n.noProductsYet));
}
// The notice takes the first row
final offset = managedOnWebsite ? 1 : 0;
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: viewModel.products.length + offset,
itemCount: viewModel.products.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
if (index < offset) return const ManagedOnWebsiteNotice();
final product = viewModel.products[index - offset];
final product = viewModel.products[index];
return Card(
child: ListTile(
@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
/// Tells that the products of a paired tablet are managed on the website.
class ManagedOnWebsiteNotice extends StatelessWidget {
const ManagedOnWebsiteNotice({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Card(
color: colors.secondaryContainer,
child: ListTile(
leading: Icon(Icons.language, color: colors.onSecondaryContainer),
title: Text(
AppLocalizations.of(context).productsManagedOnWebsite,
style: TextStyle(color: colors.onSecondaryContainer),
),
),
);
}
}