feat: add remote products and qr code for payconic
This commit is contained in:
@@ -168,6 +168,8 @@ GoRouter createAppRouter({
|
|||||||
onSet: adminPinViewModel.setPin,
|
onSet: adminPinViewModel.setPin,
|
||||||
error: () => adminPinViewModel.error,
|
error: () => adminPinViewModel.error,
|
||||||
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
|
onSuccess: () => context.go(_returnLocation(state) ?? '/admin'),
|
||||||
|
onClose: () =>
|
||||||
|
context.canPop() ? context.pop() : context.go('/bar'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ class Products extends Table {
|
|||||||
|
|
||||||
IntColumn get priceInCents => integer()();
|
IntColumn get priceInCents => integer()();
|
||||||
|
|
||||||
|
/// The picture shown on the tile: a local file, also offline.
|
||||||
TextColumn get imagePath => text().nullable()();
|
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))();
|
BoolColumn get active => boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -227,6 +232,22 @@ class StockAdjustmentResults extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
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(
|
@DriftDatabase(
|
||||||
tables: [
|
tables: [
|
||||||
Products,
|
Products,
|
||||||
@@ -240,13 +261,14 @@ class StockAdjustmentResults extends Table {
|
|||||||
ProcessedRemoteCommands,
|
ProcessedRemoteCommands,
|
||||||
TabEvents,
|
TabEvents,
|
||||||
StockAdjustmentResults,
|
StockAdjustmentResults,
|
||||||
|
PendingImageUploads,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 17;
|
int get schemaVersion => 18;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -362,6 +384,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
remoteSyncState.settlementsCursor,
|
remoteSyncState.settlementsCursor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (from < 18) {
|
||||||
|
if (!await hasTableColumn('products', 'image_id')) {
|
||||||
|
await migrator.addColumn(products, products.imageId);
|
||||||
|
}
|
||||||
|
await migrator.createTable(pendingImageUploads);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ class $ProductsTable extends Products
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
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');
|
static const VerificationMeta _activeMeta = const VerificationMeta('active');
|
||||||
@override
|
@override
|
||||||
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
|
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
|
||||||
@@ -120,6 +131,7 @@ class $ProductsTable extends Products
|
|||||||
infiniteStock,
|
infiniteStock,
|
||||||
priceInCents,
|
priceInCents,
|
||||||
imagePath,
|
imagePath,
|
||||||
|
imageId,
|
||||||
active,
|
active,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
@@ -203,6 +215,12 @@ class $ProductsTable extends Products
|
|||||||
imagePath.isAcceptableOrUnknown(data['image_path']!, _imagePathMeta),
|
imagePath.isAcceptableOrUnknown(data['image_path']!, _imagePathMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('image_id')) {
|
||||||
|
context.handle(
|
||||||
|
_imageIdMeta,
|
||||||
|
imageId.isAcceptableOrUnknown(data['image_id']!, _imageIdMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.containsKey('active')) {
|
if (data.containsKey('active')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_activeMeta,
|
_activeMeta,
|
||||||
@@ -250,6 +268,10 @@ class $ProductsTable extends Products
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}image_path'],
|
data['${effectivePrefix}image_path'],
|
||||||
),
|
),
|
||||||
|
imageId: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}image_id'],
|
||||||
|
),
|
||||||
active: attachedDatabase.typeMapping.read(
|
active: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}active'],
|
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.
|
/// Products like tap water that are never counted; their stock is ignored.
|
||||||
final bool infiniteStock;
|
final bool infiniteStock;
|
||||||
final int priceInCents;
|
final int priceInCents;
|
||||||
|
|
||||||
|
/// The picture shown on the tile: a local file, also offline.
|
||||||
final String? imagePath;
|
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;
|
final bool active;
|
||||||
const ProductRow({
|
const ProductRow({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -284,6 +312,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
required this.infiniteStock,
|
required this.infiniteStock,
|
||||||
required this.priceInCents,
|
required this.priceInCents,
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
|
this.imageId,
|
||||||
required this.active,
|
required this.active,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
@@ -299,6 +328,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
if (!nullToAbsent || imagePath != null) {
|
if (!nullToAbsent || imagePath != null) {
|
||||||
map['image_path'] = Variable<String>(imagePath);
|
map['image_path'] = Variable<String>(imagePath);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || imageId != null) {
|
||||||
|
map['image_id'] = Variable<String>(imageId);
|
||||||
|
}
|
||||||
map['active'] = Variable<bool>(active);
|
map['active'] = Variable<bool>(active);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -315,6 +347,9 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
imagePath: imagePath == null && nullToAbsent
|
imagePath: imagePath == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(imagePath),
|
: Value(imagePath),
|
||||||
|
imageId: imageId == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(imageId),
|
||||||
active: Value(active),
|
active: Value(active),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,6 +368,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
infiniteStock: serializer.fromJson<bool>(json['infiniteStock']),
|
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']),
|
||||||
|
imageId: serializer.fromJson<String?>(json['imageId']),
|
||||||
active: serializer.fromJson<bool>(json['active']),
|
active: serializer.fromJson<bool>(json['active']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -348,6 +384,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
'infiniteStock': serializer.toJson<bool>(infiniteStock),
|
'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),
|
||||||
|
'imageId': serializer.toJson<String?>(imageId),
|
||||||
'active': serializer.toJson<bool>(active),
|
'active': serializer.toJson<bool>(active),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -361,6 +398,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
bool? infiniteStock,
|
bool? infiniteStock,
|
||||||
int? priceInCents,
|
int? priceInCents,
|
||||||
Value<String?> imagePath = const Value.absent(),
|
Value<String?> imagePath = const Value.absent(),
|
||||||
|
Value<String?> imageId = const Value.absent(),
|
||||||
bool? active,
|
bool? active,
|
||||||
}) => ProductRow(
|
}) => ProductRow(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -371,6 +409,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
infiniteStock: infiniteStock ?? this.infiniteStock,
|
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,
|
||||||
|
imageId: imageId.present ? imageId.value : this.imageId,
|
||||||
active: active ?? this.active,
|
active: active ?? this.active,
|
||||||
);
|
);
|
||||||
ProductRow copyWithCompanion(ProductsCompanion data) {
|
ProductRow copyWithCompanion(ProductsCompanion data) {
|
||||||
@@ -391,6 +430,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
? data.priceInCents.value
|
? data.priceInCents.value
|
||||||
: this.priceInCents,
|
: this.priceInCents,
|
||||||
imagePath: data.imagePath.present ? data.imagePath.value : this.imagePath,
|
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,
|
active: data.active.present ? data.active.value : this.active,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -406,6 +446,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
..write('infiniteStock: $infiniteStock, ')
|
..write('infiniteStock: $infiniteStock, ')
|
||||||
..write('priceInCents: $priceInCents, ')
|
..write('priceInCents: $priceInCents, ')
|
||||||
..write('imagePath: $imagePath, ')
|
..write('imagePath: $imagePath, ')
|
||||||
|
..write('imageId: $imageId, ')
|
||||||
..write('active: $active')
|
..write('active: $active')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
@@ -421,6 +462,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
infiniteStock,
|
infiniteStock,
|
||||||
priceInCents,
|
priceInCents,
|
||||||
imagePath,
|
imagePath,
|
||||||
|
imageId,
|
||||||
active,
|
active,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
@@ -435,6 +477,7 @@ class ProductRow extends DataClass implements Insertable<ProductRow> {
|
|||||||
other.infiniteStock == this.infiniteStock &&
|
other.infiniteStock == this.infiniteStock &&
|
||||||
other.priceInCents == this.priceInCents &&
|
other.priceInCents == this.priceInCents &&
|
||||||
other.imagePath == this.imagePath &&
|
other.imagePath == this.imagePath &&
|
||||||
|
other.imageId == this.imageId &&
|
||||||
other.active == this.active);
|
other.active == this.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +490,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
final Value<bool> infiniteStock;
|
final Value<bool> infiniteStock;
|
||||||
final Value<int> priceInCents;
|
final Value<int> priceInCents;
|
||||||
final Value<String?> imagePath;
|
final Value<String?> imagePath;
|
||||||
|
final Value<String?> imageId;
|
||||||
final Value<bool> active;
|
final Value<bool> active;
|
||||||
final Value<int> rowid;
|
final Value<int> rowid;
|
||||||
const ProductsCompanion({
|
const ProductsCompanion({
|
||||||
@@ -458,6 +502,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
this.infiniteStock = 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.imageId = const Value.absent(),
|
||||||
this.active = const Value.absent(),
|
this.active = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
});
|
});
|
||||||
@@ -470,6 +515,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
this.infiniteStock = const Value.absent(),
|
this.infiniteStock = const Value.absent(),
|
||||||
required int priceInCents,
|
required int priceInCents,
|
||||||
this.imagePath = const Value.absent(),
|
this.imagePath = const Value.absent(),
|
||||||
|
this.imageId = const Value.absent(),
|
||||||
this.active = const Value.absent(),
|
this.active = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
}) : id = Value(id),
|
}) : id = Value(id),
|
||||||
@@ -487,6 +533,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
Expression<bool>? infiniteStock,
|
Expression<bool>? infiniteStock,
|
||||||
Expression<int>? priceInCents,
|
Expression<int>? priceInCents,
|
||||||
Expression<String>? imagePath,
|
Expression<String>? imagePath,
|
||||||
|
Expression<String>? imageId,
|
||||||
Expression<bool>? active,
|
Expression<bool>? active,
|
||||||
Expression<int>? rowid,
|
Expression<int>? rowid,
|
||||||
}) {
|
}) {
|
||||||
@@ -499,6 +546,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
if (infiniteStock != null) 'infinite_stock': infiniteStock,
|
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 (imageId != null) 'image_id': imageId,
|
||||||
if (active != null) 'active': active,
|
if (active != null) 'active': active,
|
||||||
if (rowid != null) 'rowid': rowid,
|
if (rowid != null) 'rowid': rowid,
|
||||||
});
|
});
|
||||||
@@ -513,6 +561,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
Value<bool>? infiniteStock,
|
Value<bool>? infiniteStock,
|
||||||
Value<int>? priceInCents,
|
Value<int>? priceInCents,
|
||||||
Value<String?>? imagePath,
|
Value<String?>? imagePath,
|
||||||
|
Value<String?>? imageId,
|
||||||
Value<bool>? active,
|
Value<bool>? active,
|
||||||
Value<int>? rowid,
|
Value<int>? rowid,
|
||||||
}) {
|
}) {
|
||||||
@@ -525,6 +574,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
infiniteStock: infiniteStock ?? this.infiniteStock,
|
infiniteStock: infiniteStock ?? this.infiniteStock,
|
||||||
priceInCents: priceInCents ?? this.priceInCents,
|
priceInCents: priceInCents ?? this.priceInCents,
|
||||||
imagePath: imagePath ?? this.imagePath,
|
imagePath: imagePath ?? this.imagePath,
|
||||||
|
imageId: imageId ?? this.imageId,
|
||||||
active: active ?? this.active,
|
active: active ?? this.active,
|
||||||
rowid: rowid ?? this.rowid,
|
rowid: rowid ?? this.rowid,
|
||||||
);
|
);
|
||||||
@@ -557,6 +607,9 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
if (imagePath.present) {
|
if (imagePath.present) {
|
||||||
map['image_path'] = Variable<String>(imagePath.value);
|
map['image_path'] = Variable<String>(imagePath.value);
|
||||||
}
|
}
|
||||||
|
if (imageId.present) {
|
||||||
|
map['image_id'] = Variable<String>(imageId.value);
|
||||||
|
}
|
||||||
if (active.present) {
|
if (active.present) {
|
||||||
map['active'] = Variable<bool>(active.value);
|
map['active'] = Variable<bool>(active.value);
|
||||||
}
|
}
|
||||||
@@ -577,6 +630,7 @@ class ProductsCompanion extends UpdateCompanion<ProductRow> {
|
|||||||
..write('infiniteStock: $infiniteStock, ')
|
..write('infiniteStock: $infiniteStock, ')
|
||||||
..write('priceInCents: $priceInCents, ')
|
..write('priceInCents: $priceInCents, ')
|
||||||
..write('imagePath: $imagePath, ')
|
..write('imagePath: $imagePath, ')
|
||||||
|
..write('imageId: $imageId, ')
|
||||||
..write('active: $active, ')
|
..write('active: $active, ')
|
||||||
..write('rowid: $rowid')
|
..write('rowid: $rowid')
|
||||||
..write(')'))
|
..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 {
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
_$AppDatabase(QueryExecutor e) : super(e);
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
@@ -4469,6 +4795,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
late final $TabEventsTable tabEvents = $TabEventsTable(this);
|
late final $TabEventsTable tabEvents = $TabEventsTable(this);
|
||||||
late final $StockAdjustmentResultsTable stockAdjustmentResults =
|
late final $StockAdjustmentResultsTable stockAdjustmentResults =
|
||||||
$StockAdjustmentResultsTable(this);
|
$StockAdjustmentResultsTable(this);
|
||||||
|
late final $PendingImageUploadsTable pendingImageUploads =
|
||||||
|
$PendingImageUploadsTable(this);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
@@ -4485,6 +4813,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
processedRemoteCommands,
|
processedRemoteCommands,
|
||||||
tabEvents,
|
tabEvents,
|
||||||
stockAdjustmentResults,
|
stockAdjustmentResults,
|
||||||
|
pendingImageUploads,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
||||||
@@ -4515,6 +4844,7 @@ typedef $$ProductsTableCreateCompanionBuilder =
|
|||||||
Value<bool> infiniteStock,
|
Value<bool> infiniteStock,
|
||||||
required int priceInCents,
|
required int priceInCents,
|
||||||
Value<String?> imagePath,
|
Value<String?> imagePath,
|
||||||
|
Value<String?> imageId,
|
||||||
Value<bool> active,
|
Value<bool> active,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
});
|
});
|
||||||
@@ -4528,6 +4858,7 @@ typedef $$ProductsTableUpdateCompanionBuilder =
|
|||||||
Value<bool> infiniteStock,
|
Value<bool> infiniteStock,
|
||||||
Value<int> priceInCents,
|
Value<int> priceInCents,
|
||||||
Value<String?> imagePath,
|
Value<String?> imagePath,
|
||||||
|
Value<String?> imageId,
|
||||||
Value<bool> active,
|
Value<bool> active,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
});
|
});
|
||||||
@@ -4604,6 +4935,11 @@ class $$ProductsTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get imageId => $composableBuilder(
|
||||||
|
column: $table.imageId,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<bool> get active => $composableBuilder(
|
ColumnFilters<bool> get active => $composableBuilder(
|
||||||
column: $table.active,
|
column: $table.active,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
@@ -4684,6 +5020,11 @@ class $$ProductsTableOrderingComposer
|
|||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get imageId => $composableBuilder(
|
||||||
|
column: $table.imageId,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<bool> get active => $composableBuilder(
|
ColumnOrderings<bool> get active => $composableBuilder(
|
||||||
column: $table.active,
|
column: $table.active,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
@@ -4731,6 +5072,9 @@ class $$ProductsTableAnnotationComposer
|
|||||||
GeneratedColumn<String> get imagePath =>
|
GeneratedColumn<String> get imagePath =>
|
||||||
$composableBuilder(column: $table.imagePath, builder: (column) => column);
|
$composableBuilder(column: $table.imagePath, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get imageId =>
|
||||||
|
$composableBuilder(column: $table.imageId, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<bool> get active =>
|
GeneratedColumn<bool> get active =>
|
||||||
$composableBuilder(column: $table.active, builder: (column) => column);
|
$composableBuilder(column: $table.active, builder: (column) => column);
|
||||||
|
|
||||||
@@ -4796,6 +5140,7 @@ class $$ProductsTableTableManager
|
|||||||
Value<bool> infiniteStock = 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<String?> imageId = const Value.absent(),
|
||||||
Value<bool> active = const Value.absent(),
|
Value<bool> active = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
}) => ProductsCompanion(
|
}) => ProductsCompanion(
|
||||||
@@ -4807,6 +5152,7 @@ class $$ProductsTableTableManager
|
|||||||
infiniteStock: infiniteStock,
|
infiniteStock: infiniteStock,
|
||||||
priceInCents: priceInCents,
|
priceInCents: priceInCents,
|
||||||
imagePath: imagePath,
|
imagePath: imagePath,
|
||||||
|
imageId: imageId,
|
||||||
active: active,
|
active: active,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
@@ -4820,6 +5166,7 @@ class $$ProductsTableTableManager
|
|||||||
Value<bool> infiniteStock = const Value.absent(),
|
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<String?> imageId = const Value.absent(),
|
||||||
Value<bool> active = const Value.absent(),
|
Value<bool> active = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
}) => ProductsCompanion.insert(
|
}) => ProductsCompanion.insert(
|
||||||
@@ -4831,6 +5178,7 @@ class $$ProductsTableTableManager
|
|||||||
infiniteStock: infiniteStock,
|
infiniteStock: infiniteStock,
|
||||||
priceInCents: priceInCents,
|
priceInCents: priceInCents,
|
||||||
imagePath: imagePath,
|
imagePath: imagePath,
|
||||||
|
imageId: imageId,
|
||||||
active: active,
|
active: active,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
@@ -7549,6 +7897,184 @@ typedef $$StockAdjustmentResultsTableProcessedTableManager =
|
|||||||
StockAdjustmentResultRow,
|
StockAdjustmentResultRow,
|
||||||
PrefetchHooks Function()
|
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 {
|
class $AppDatabaseManager {
|
||||||
final _$AppDatabase _db;
|
final _$AppDatabase _db;
|
||||||
@@ -7581,4 +8107,6 @@ class $AppDatabaseManager {
|
|||||||
_db,
|
_db,
|
||||||
_db.stockAdjustmentResults,
|
_db.stockAdjustmentResults,
|
||||||
);
|
);
|
||||||
|
$$PendingImageUploadsTableTableManager get pendingImageUploads =>
|
||||||
|
$$PendingImageUploadsTableTableManager(_db, _db.pendingImageUploads);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,184 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
import '../models/remote_command.dart';
|
import '../models/remote_command.dart';
|
||||||
|
import '../models/tab_event.dart';
|
||||||
import 'app_database.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 {
|
extension CatalogQueries on AppDatabase {
|
||||||
/// Makes the local products match [catalog]: new products are added,
|
/// Makes the local products match [catalog], then replays this tablet's
|
||||||
/// changed ones updated, archived ones and products the server doesn't
|
/// product changes the server hasn't applied yet on top, so an offline
|
||||||
/// know (such as the default products of a new tablet) are hidden. Hidden,
|
/// edit stays visible until the server has it (the same idea as the tabs).
|
||||||
/// not deleted: open tabs and the history refer to them.
|
|
||||||
///
|
///
|
||||||
/// The stock is not touched here: the stock rebase that follows sets it, a
|
/// New products are added; archived ones, and products the server doesn't
|
||||||
/// new product starts as untracked until then. Local images are kept.
|
/// 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) {
|
Future<void> applyServerCatalog(List<ServerProduct> catalog) {
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
final local = {
|
final rows = {
|
||||||
for (final row in await select(products).get()) row.id: row,
|
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) {
|
for (final product in catalog) {
|
||||||
final category = product.category ?? '';
|
listed.add(product.id);
|
||||||
final lowStockThreshold = product.lowStockThreshold ?? 0;
|
final local = desired[product.id];
|
||||||
final existing = local.remove(product.id);
|
final next = _Product.fromServer(product);
|
||||||
|
if (local == null) {
|
||||||
if (existing == null) {
|
desired[product.id] = next;
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (uploading.contains(product.id)) {
|
||||||
if (existing.name == product.name &&
|
next
|
||||||
existing.category == category &&
|
..imageId = local.imageId
|
||||||
existing.priceInCents == product.priceInCents &&
|
..imagePath = local.imagePath;
|
||||||
existing.lowStockThreshold == lowStockThreshold &&
|
} else if (next.imageId != null && next.imageId == local.imageId) {
|
||||||
existing.active == !product.archived) {
|
next.imagePath = local.imagePath;
|
||||||
continue;
|
} else if (next.imageId != null) {
|
||||||
|
// Until the new picture is downloaded, the old one is better than none
|
||||||
|
next.imagePath = local.imagePath;
|
||||||
}
|
}
|
||||||
await (update(
|
desired[product.id] = next;
|
||||||
products,
|
}
|
||||||
)..where((row) => row.id.equals(product.id))).write(
|
for (final entry in desired.entries) {
|
||||||
ProductsCompanion(
|
if (!listed.contains(entry.key)) entry.value.active = false;
|
||||||
name: Value(product.name),
|
|
||||||
category: Value(category),
|
|
||||||
priceInCents: Value(product.priceInCents),
|
|
||||||
lowStockThreshold: Value(lowStockThreshold),
|
|
||||||
active: Value(!product.archived),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final unknown = [
|
for (final event in await _pendingProductEvents()) {
|
||||||
for (final row in local.values)
|
final productId = event['productId'] as String?;
|
||||||
if (row.active) row.id,
|
final local = productId == null ? null : desired[productId];
|
||||||
];
|
if (local == null) continue;
|
||||||
if (unknown.isNotEmpty) {
|
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
|
||||||
await (update(products)..where((row) => row.id.isIn(unknown))).write(
|
case TabEventType.productCreated || TabEventType.productUpdated:
|
||||||
const ProductsCompanion(active: Value(false)),
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ extension StockQueries on AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queues a `stock_set` event, so a stock count or tracking change made on
|
/// Queues an event that is not about a tab (a stock count, a product
|
||||||
/// this tablet reaches the server. Call it inside the transaction that
|
/// change) in the outbox. Call it inside the transaction that makes the
|
||||||
/// writes the stock.
|
/// change, so the change and its event are stored together.
|
||||||
Future<void> queueStockSet(TabEvent event) async {
|
Future<void> queueEvent(TabEvent event) async {
|
||||||
await into(tabEvents).insert(
|
await into(tabEvents).insert(
|
||||||
TabEventsCompanion.insert(
|
TabEventsCompanion.insert(
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|||||||
+7
-2
@@ -4,7 +4,6 @@
|
|||||||
"barTabs": "Bar Tabs",
|
"barTabs": "Bar Tabs",
|
||||||
"products": "Products",
|
"products": "Products",
|
||||||
"addProduct": "Add product",
|
"addProduct": "Add product",
|
||||||
"productsManagedOnWebsite": "Products are managed on the website. Here you can change the stock and the picture.",
|
|
||||||
"noProductsYet": "No products yet.",
|
"noProductsYet": "No products yet.",
|
||||||
"addFirstProduct": "Add your first product to start selling.",
|
"addFirstProduct": "Add your first product to start selling.",
|
||||||
"manageProducts": "Manage products",
|
"manageProducts": "Manage products",
|
||||||
@@ -319,5 +318,11 @@
|
|||||||
"registeringDevice": "Registering device…",
|
"registeringDevice": "Registering device…",
|
||||||
"deviceRegistered": "Device registered!",
|
"deviceRegistered": "Device registered!",
|
||||||
"openTabsWillSync": "Open tabs will sync to the dashboard.",
|
"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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,12 +122,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Add product'**
|
/// **'Add product'**
|
||||||
String get addProduct;
|
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.
|
/// No description provided for @noProductsYet.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1885,6 +1879,12 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Done'**
|
/// **'Done'**
|
||||||
String get 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addProduct => 'Add product';
|
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
|
@override
|
||||||
String get noProductsYet => 'No products yet.';
|
String get noProductsYet => 'No products yet.';
|
||||||
|
|
||||||
@@ -980,4 +976,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get done => 'Done';
|
String get done => 'Done';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String scanToPay(String amount) {
|
||||||
|
return 'Let the customer scan this code to pay $amount';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get addProduct => 'Product toevoegen';
|
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
|
@override
|
||||||
String get noProductsYet => 'Nog geen producten.';
|
String get noProductsYet => 'Nog geen producten.';
|
||||||
|
|
||||||
@@ -993,4 +989,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get done => 'Klaar';
|
String get done => 'Klaar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String scanToPay(String amount) {
|
||||||
|
return 'Laat de klant deze code scannen om $amount te betalen';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,6 @@
|
|||||||
"barTabs": "Poefs",
|
"barTabs": "Poefs",
|
||||||
"products": "Producten",
|
"products": "Producten",
|
||||||
"addProduct": "Product toevoegen",
|
"addProduct": "Product toevoegen",
|
||||||
"productsManagedOnWebsite": "Producten beheer je op de website. Hier pas je de voorraad en de foto aan.",
|
|
||||||
"noProductsYet": "Nog geen producten.",
|
"noProductsYet": "Nog geen producten.",
|
||||||
"addFirstProduct": "Voeg je eerste product toe om te beginnen.",
|
"addFirstProduct": "Voeg je eerste product toe om te beginnen.",
|
||||||
"manageProducts": "Producten beheren",
|
"manageProducts": "Producten beheren",
|
||||||
@@ -318,5 +317,6 @@
|
|||||||
"registeringDevice": "Toestel registreren…",
|
"registeringDevice": "Toestel registreren…",
|
||||||
"deviceRegistered": "Toestel geregistreerd!",
|
"deviceRegistered": "Toestel geregistreerd!",
|
||||||
"openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.",
|
"openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.",
|
||||||
"done": "Klaar"
|
"done": "Klaar",
|
||||||
|
"scanToPay": "Laat de klant deze code scannen om {amount} te betalen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:intl/intl.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:provider/provider.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.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/bar_tab_service.dart';
|
||||||
import 'services/kooltap_api_client.dart';
|
import 'services/kooltap_api_client.dart';
|
||||||
import 'services/pin_lock_service.dart';
|
import 'services/pin_lock_service.dart';
|
||||||
|
import 'services/product_image_sync.dart';
|
||||||
import 'services/product_service.dart';
|
import 'services/product_service.dart';
|
||||||
import 'services/remote_sync_service.dart';
|
import 'services/remote_sync_service.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
@@ -70,6 +75,16 @@ Future<void> _runApp() async {
|
|||||||
database: database,
|
database: database,
|
||||||
barTabService: barTabService,
|
barTabService: barTabService,
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
|
imageSync: ProductImageSync(
|
||||||
|
database: database,
|
||||||
|
apiClient: apiClient,
|
||||||
|
imagesDirectory: () async => Directory(
|
||||||
|
path.join(
|
||||||
|
(await getApplicationSupportDirectory()).path,
|
||||||
|
'product_images',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final pinLockViewModel = PinLockViewModel(
|
final pinLockViewModel = PinLockViewModel(
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ class ServerProduct {
|
|||||||
/// Deleted on the website: hidden, kept for open tabs and the history.
|
/// Deleted on the website: hidden, kept for open tabs and the history.
|
||||||
final bool archived;
|
final bool archived;
|
||||||
|
|
||||||
|
/// The picture, at `/images/<imageId>`; null: none.
|
||||||
|
final String? imageId;
|
||||||
|
|
||||||
const ServerProduct({
|
const ServerProduct({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -177,6 +180,7 @@ class ServerProduct {
|
|||||||
required this.priceInCents,
|
required this.priceInCents,
|
||||||
this.lowStockThreshold,
|
this.lowStockThreshold,
|
||||||
this.archived = false,
|
this.archived = false,
|
||||||
|
this.imageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServerProduct.fromJson(Map<String, dynamic> json) {
|
factory ServerProduct.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -187,6 +191,7 @@ class ServerProduct {
|
|||||||
priceInCents: json['priceInCents'] as int,
|
priceInCents: json['priceInCents'] as int,
|
||||||
lowStockThreshold: json['lowStockThreshold'] as int?,
|
lowStockThreshold: json['lowStockThreshold'] as int?,
|
||||||
archived: json['archived'] as bool? ?? false,
|
archived: json['archived'] as bool? ?? false,
|
||||||
|
imageId: json['imageId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ enum TabEventType {
|
|||||||
|
|
||||||
/// A stock count or tracking change made on the tablet. Not about a tab:
|
/// A stock count or tracking change made on the tablet. Not about a tab:
|
||||||
/// it has no `tabId`.
|
/// 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;
|
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`,
|
/// The server's payment method names: `cash`, `payconiq`,
|
||||||
/// `payment_pending` or `other`.
|
/// `payment_pending` or `other`.
|
||||||
static String paymentMethodValue(PaymentMethod method) {
|
static String paymentMethodValue(PaymentMethod method) {
|
||||||
|
|||||||
@@ -892,8 +892,12 @@ class DriftBarTabService implements BarTabService {
|
|||||||
for (final item in event['items'] as List<dynamic>? ?? []) {
|
for (final item in event['items'] as List<dynamic>? ?? []) {
|
||||||
await _replaySettledLine(tabId, item as Map<String, 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.stockSet:
|
||||||
|
case TabEventType.productCreated:
|
||||||
|
case TabEventType.productUpdated:
|
||||||
|
case TabEventType.productArchived:
|
||||||
case null:
|
case null:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class DefaultProductSeeder {
|
|||||||
imagePath: Value(imagePath),
|
imagePath: Value(imagePath),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await database.queueStockSet(
|
await database.queueEvent(
|
||||||
events.stockSet(productId: id, quantity: stock),
|
events.stockSet(productId: id, quantity: stock),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
Future<Object?> _post(
|
||||||
String path,
|
String path,
|
||||||
Map<String, dynamic> body, {
|
Map<String, dynamic> body, {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
|
import '../database/catalog_queries.dart';
|
||||||
import '../database/stock_queries.dart';
|
import '../database/stock_queries.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/tab_event.dart';
|
import '../models/tab_event.dart';
|
||||||
@@ -87,6 +88,8 @@ class DriftProductService implements ProductService {
|
|||||||
return _mapRowToProduct(row);
|
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
|
@override
|
||||||
Future<void> createProduct({
|
Future<void> createProduct({
|
||||||
required String name,
|
required String name,
|
||||||
@@ -114,16 +117,29 @@ class DriftProductService implements ProductService {
|
|||||||
imagePath: Value(imagePath),
|
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
|
// The server starts a product it doesn't know as untracked
|
||||||
if (!infiniteStock) {
|
if (!infiniteStock) {
|
||||||
await database.queueStockSet(
|
await database.queueEvent(
|
||||||
eventFactory.stockSet(productId: id, quantity: stockQuantity),
|
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
|
@override
|
||||||
Future<void> updateProduct(Product product) async {
|
Future<void> updateProduct(Product product) async {
|
||||||
await database.transaction(() async {
|
await database.transaction(() async {
|
||||||
@@ -147,28 +163,57 @@ class DriftProductService implements ProductService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (current == null) return;
|
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
|
final stockChanged = product.infiniteStock
|
||||||
? !current.infiniteStock
|
? !current.infiniteStock
|
||||||
: current.infiniteStock ||
|
: current.infiniteStock ||
|
||||||
current.stockQuantity != product.stockQuantity;
|
current.stockQuantity != product.stockQuantity;
|
||||||
if (stockChanged) {
|
if (stockChanged) {
|
||||||
await database.queueStockSet(
|
await database.queueEvent(
|
||||||
eventFactory.stockSet(
|
eventFactory.stockSet(
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
quantity: product.infiniteStock ? null : product.stockQuantity,
|
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
|
@override
|
||||||
Future<void> deleteProduct(String id) async {
|
Future<void> deleteProduct(String id) async {
|
||||||
final query = database.update(database.products)
|
await database.transaction(() async {
|
||||||
..where((product) => product.id.equals(id));
|
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
|
@override
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../models/remote_command.dart';
|
|||||||
import '../utils/app_config.dart';
|
import '../utils/app_config.dart';
|
||||||
import 'bar_tab_service.dart';
|
import 'bar_tab_service.dart';
|
||||||
import 'kooltap_api_client.dart';
|
import 'kooltap_api_client.dart';
|
||||||
|
import 'product_image_sync.dart';
|
||||||
|
|
||||||
typedef _Credentials = ({String deviceId, String token});
|
typedef _Credentials = ({String deviceId, String token});
|
||||||
|
|
||||||
@@ -38,6 +39,10 @@ class RemoteSyncService {
|
|||||||
final KoolTapApiClient apiClient;
|
final KoolTapApiClient apiClient;
|
||||||
final FlutterSecureStorage secureStorage;
|
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.
|
/// How long tab changes must be quiet before they are synced.
|
||||||
final Duration changeDebounce;
|
final Duration changeDebounce;
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ class RemoteSyncService {
|
|||||||
required this.barTabService,
|
required this.barTabService,
|
||||||
required this.apiClient,
|
required this.apiClient,
|
||||||
FlutterSecureStorage? secureStorage,
|
FlutterSecureStorage? secureStorage,
|
||||||
|
this.imageSync,
|
||||||
this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs),
|
this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs),
|
||||||
this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs),
|
this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs),
|
||||||
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
|
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||||
@@ -222,6 +228,14 @@ class RemoteSyncService {
|
|||||||
try {
|
try {
|
||||||
final appliedStock = await _doSync(credentials);
|
final appliedStock = await _doSync(credentials);
|
||||||
_consecutiveFailures = 0;
|
_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
|
// Show the new stock on the dashboard within seconds. The follow-up
|
||||||
// skips the adjustments it already applied, so this doesn't loop.
|
// skips the adjustments it already applied, so this doesn't loop.
|
||||||
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
|
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
|
||||||
@@ -278,6 +292,21 @@ class RemoteSyncService {
|
|||||||
/// Returns whether any stock adjustment was applied, which only happens
|
/// Returns whether any stock adjustment was applied, which only happens
|
||||||
/// with a server that doesn't own the stock.
|
/// with a server that doesn't own the stock.
|
||||||
Future<bool> _doSync(_Credentials credentials) async {
|
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();
|
final state = await _getSyncState();
|
||||||
// One read, so the snapshot and the stock match the events that are sent
|
// One read, so the snapshot and the stock match the events that are sent
|
||||||
final (outbox, openTabs, products, stockResults) = await database
|
final (outbox, openTabs, products, stockResults) = await database
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
|
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 {
|
class _PaymentPicker extends StatelessWidget {
|
||||||
final PaymentMethod selected;
|
final PaymentMethod selected;
|
||||||
final ValueChanged<PaymentMethod> onChanged;
|
final ValueChanged<PaymentMethod> onChanged;
|
||||||
|
|
||||||
const _PaymentPicker({required this.selected, required this.onChanged});
|
const _PaymentPicker({required this.selected, required this.onChanged});
|
||||||
|
|
||||||
static const _options = [
|
static const _options = [PaymentMethod.cash, PaymentMethod.payconiq];
|
||||||
PaymentMethod.cash,
|
|
||||||
PaymentMethod.payconiq,
|
|
||||||
PaymentMethod.paymentPending,
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class PinEntryView extends StatefulWidget {
|
|||||||
final Future<bool> Function(String)? onVerify;
|
final Future<bool> Function(String)? onVerify;
|
||||||
final Future<bool> Function(String)? onSet;
|
final Future<bool> Function(String)? onSet;
|
||||||
final AppError? Function()? error;
|
final AppError? Function()? error;
|
||||||
final bool showBack;
|
/// Shows a close button that leaves the screen without entering a PIN.
|
||||||
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
const PinEntryView({
|
const PinEntryView({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -30,7 +31,7 @@ class PinEntryView extends StatefulWidget {
|
|||||||
this.onVerify,
|
this.onVerify,
|
||||||
this.onSet,
|
this.onSet,
|
||||||
this.error,
|
this.error,
|
||||||
this.showBack = false,
|
this.onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -185,18 +186,16 @@ class _PinEntryViewState extends State<PinEntryView> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.showBack) ...[
|
Align(
|
||||||
const Spacer(flex: 1),
|
alignment: Alignment.centerLeft,
|
||||||
Row(
|
child: widget.onClose != null
|
||||||
children: [
|
? IconButton(
|
||||||
IconButton(
|
onPressed: _isSubmitting ? null : widget.onClose,
|
||||||
onPressed: () => {},
|
icon: const Icon(Icons.close),
|
||||||
icon: const Icon(Icons.close),
|
tooltip: l10n.close,
|
||||||
tooltip: 'Back',
|
)
|
||||||
),
|
: const SizedBox(height: 48),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
const Spacer(flex: 2),
|
const Spacer(flex: 2),
|
||||||
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
|
Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import '../models/product.dart';
|
|||||||
import '../utils/money.dart';
|
import '../utils/money.dart';
|
||||||
import '../utils/navigation.dart';
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/product_list_view_model.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 {
|
class ProductFormView extends StatefulWidget {
|
||||||
final String? productId;
|
final String? productId;
|
||||||
@@ -315,9 +313,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final title = widget.isEditing ? l10n.editProduct : l10n.addProduct;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -327,7 +322,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
onPressed: _leaveProductForm,
|
onPressed: _leaveProductForm,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (widget.isEditing && !managedOnWebsite)
|
if (widget.isEditing)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _delete,
|
onPressed: _delete,
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
@@ -347,15 +342,10 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
children: [
|
children: [
|
||||||
if (managedOnWebsite) ...[
|
|
||||||
const ManagedOnWebsiteNotice(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
_buildImagePicker(context),
|
_buildImagePicker(context),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
enabled: !managedOnWebsite,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.productName,
|
labelText: l10n.productName,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -376,7 +366,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return DropdownMenu<String>(
|
return DropdownMenu<String>(
|
||||||
width: constraints.maxWidth,
|
width: constraints.maxWidth,
|
||||||
enabled: !managedOnWebsite,
|
|
||||||
initialSelection: _selectedCategory,
|
initialSelection: _selectedCategory,
|
||||||
enableFilter: true,
|
enableFilter: true,
|
||||||
enableSearch: true,
|
enableSearch: true,
|
||||||
@@ -406,7 +395,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _priceController,
|
controller: _priceController,
|
||||||
enabled: !managedOnWebsite,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.price,
|
labelText: l10n.price,
|
||||||
prefixText: '€ ',
|
prefixText: '€ ',
|
||||||
@@ -467,7 +455,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _lowStockController,
|
controller: _lowStockController,
|
||||||
enabled: !managedOnWebsite,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.lowStockThreshold,
|
labelText: l10n.lowStockThreshold,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import '../l10n/app_localizations_helpers.dart';
|
|||||||
import '../utils/money.dart';
|
import '../utils/money.dart';
|
||||||
import '../utils/navigation.dart';
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/product_list_view_model.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 {
|
class ProductListView extends StatefulWidget {
|
||||||
const ProductListView({super.key});
|
const ProductListView({super.key});
|
||||||
@@ -33,8 +31,6 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final viewModel = context.watch<ProductListViewModel>();
|
final viewModel = context.watch<ProductListViewModel>();
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
// A paired tablet gets its products from the website
|
|
||||||
final managedOnWebsite = context.watch<RemoteSyncViewModel>().isConfigured;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -45,13 +41,11 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
title: Text(l10n.products),
|
title: Text(l10n.products),
|
||||||
),
|
),
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
floatingActionButton: managedOnWebsite
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
? null
|
onPressed: () => context.push('/products/new'),
|
||||||
: FloatingActionButton.extended(
|
icon: const Icon(Icons.add),
|
||||||
onPressed: () => context.push('/products/new'),
|
label: Text(l10n.addProduct),
|
||||||
icon: const Icon(Icons.add),
|
),
|
||||||
label: Text(l10n.addProduct),
|
|
||||||
),
|
|
||||||
body: Builder(
|
body: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
if (viewModel.isLoading) {
|
if (viewModel.isLoading) {
|
||||||
@@ -62,19 +56,16 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
return Center(child: Text(l10n.errorText(viewModel.loadError)));
|
return Center(child: Text(l10n.errorText(viewModel.loadError)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewModel.products.isEmpty && !managedOnWebsite) {
|
if (viewModel.products.isEmpty) {
|
||||||
return Center(child: Text(l10n.noProductsYet));
|
return Center(child: Text(l10n.noProductsYet));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The notice takes the first row
|
|
||||||
final offset = managedOnWebsite ? 1 : 0;
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: viewModel.products.length + offset,
|
itemCount: viewModel.products.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index < offset) return const ManagedOnWebsiteNotice();
|
final product = viewModel.products[index];
|
||||||
final product = viewModel.products[index - offset];
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: ListTile(
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-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.3.4+0
|
version: 1.3.8+0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -51,4 +51,5 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/icons/payconic.svg
|
- assets/icons/payconic.svg
|
||||||
|
- assets/payconiq/
|
||||||
- assets/products/
|
- assets/products/
|
||||||
|
|||||||
@@ -102,4 +102,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect((await database.select(database.products).get()), hasLength(1));
|
expect((await database.select(database.products).get()), hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gives products no server picture yet and an empty upload queue', () async {
|
||||||
|
final product = await database.select(database.products).getSingle();
|
||||||
|
|
||||||
|
expect(product.imageId, isNull);
|
||||||
|
expect(await database.select(database.pendingImageUploads).get(), isEmpty);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import 'package:kooltap/models/product.dart';
|
|||||||
import 'package:kooltap/models/remote_command.dart';
|
import 'package:kooltap/models/remote_command.dart';
|
||||||
import 'package:kooltap/services/bar_tab_service.dart';
|
import 'package:kooltap/services/bar_tab_service.dart';
|
||||||
import 'package:kooltap/services/kooltap_api_client.dart';
|
import 'package:kooltap/services/kooltap_api_client.dart';
|
||||||
|
import 'package:kooltap/services/product_image_sync.dart';
|
||||||
import 'package:kooltap/services/product_service.dart';
|
import 'package:kooltap/services/product_service.dart';
|
||||||
import 'package:kooltap/services/remote_sync_service.dart';
|
import 'package:kooltap/services/remote_sync_service.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
import '../helpers/memory_secure_storage.dart';
|
import '../helpers/memory_secure_storage.dart';
|
||||||
|
|
||||||
@@ -74,6 +76,30 @@ class _Website {
|
|||||||
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
|
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uploads a picture for a product, as the products page does.
|
||||||
|
Future<Map<String, dynamic>> uploadImage(
|
||||||
|
String productId,
|
||||||
|
List<int> png,
|
||||||
|
) async {
|
||||||
|
final request =
|
||||||
|
http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('$_api/venues/$venueId/stock/products/$productId/image'),
|
||||||
|
)
|
||||||
|
..headers['Authorization'] = 'Bearer $token'
|
||||||
|
..files.add(
|
||||||
|
http.MultipartFile.fromBytes('file', png, filename: 'photo.png'),
|
||||||
|
);
|
||||||
|
return _decode(await http.Response.fromStream(await request.send()))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The products page: every product with its stock and picture.
|
||||||
|
Future<List<dynamic>> products() async {
|
||||||
|
final body = await call('GET', '/venues/$venueId/stock');
|
||||||
|
return (body as Map<String, dynamic>)['products'] as List<dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<dynamic>> tabs() async {
|
Future<List<dynamic>> tabs() async {
|
||||||
final body = await call('GET', '/venues/$venueId/tabs');
|
final body = await call('GET', '/venues/$venueId/tabs');
|
||||||
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
|
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
|
||||||
@@ -93,9 +119,18 @@ class _Tablet {
|
|||||||
final DriftBarTabService tabs;
|
final DriftBarTabService tabs;
|
||||||
final DriftProductService products;
|
final DriftProductService products;
|
||||||
final RemoteSyncService sync;
|
final RemoteSyncService sync;
|
||||||
|
final ProductImageSync images;
|
||||||
|
final Directory pictures;
|
||||||
final List<RemoteSyncEvent> _events = [];
|
final List<RemoteSyncEvent> _events = [];
|
||||||
|
|
||||||
_Tablet._(this.database, this.tabs, this.products, this.sync) {
|
_Tablet._(
|
||||||
|
this.database,
|
||||||
|
this.tabs,
|
||||||
|
this.products,
|
||||||
|
this.sync,
|
||||||
|
this.images,
|
||||||
|
this.pictures,
|
||||||
|
) {
|
||||||
sync.events.listen(_events.add);
|
sync.events.listen(_events.add);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +138,18 @@ class _Tablet {
|
|||||||
final database = AppDatabase(NativeDatabase.memory());
|
final database = AppDatabase(NativeDatabase.memory());
|
||||||
final tabs = DriftBarTabService(database: database);
|
final tabs = DriftBarTabService(database: database);
|
||||||
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
|
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
|
||||||
|
final pictures = await Directory.systemTemp.createTemp('kooltap_e2e_');
|
||||||
|
final images = ProductImageSync(
|
||||||
|
database: database,
|
||||||
|
apiClient: api,
|
||||||
|
imagesDirectory: () async => pictures,
|
||||||
|
);
|
||||||
final sync = RemoteSyncService(
|
final sync = RemoteSyncService(
|
||||||
database: database,
|
database: database,
|
||||||
barTabService: tabs,
|
barTabService: tabs,
|
||||||
apiClient: api,
|
apiClient: api,
|
||||||
secureStorage: MemorySecureStorage(),
|
secureStorage: MemorySecureStorage(),
|
||||||
|
imageSync: images,
|
||||||
);
|
);
|
||||||
final credentials = await api.registerDevice(
|
final credentials = await api.registerDevice(
|
||||||
venueId: website.venueId,
|
venueId: website.venueId,
|
||||||
@@ -124,6 +166,8 @@ class _Tablet {
|
|||||||
tabs,
|
tabs,
|
||||||
DriftProductService(database: database),
|
DriftProductService(database: database),
|
||||||
sync,
|
sync,
|
||||||
|
images,
|
||||||
|
pictures,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +190,16 @@ class _Tablet {
|
|||||||
return (await products.getProducts()).singleWhere((p) => p.name == name);
|
return (await products.getProducts()).singleWhere((p) => p.name == name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Syncs, then waits for the pictures it started downloading.
|
||||||
|
Future<void> syncWithPictures() async {
|
||||||
|
await syncNow();
|
||||||
|
await images.downloadMissing();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
sync.dispose();
|
sync.dispose();
|
||||||
await database.close();
|
await database.close();
|
||||||
|
await pictures.delete(recursive: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,4 +355,105 @@ void main() {
|
|||||||
skip: _skip,
|
skip: _skip,
|
||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'pictures, and products edited on a tablet',
|
||||||
|
() async {
|
||||||
|
final suffix = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
// A 40x60 red PNG: taller than wide, so fitted whole
|
||||||
|
final png = base64Decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAACgAAAA8CAIAAACb22+3AAAAOElEQVR4nO3NQQkAAAgEsEty/aMYyxgiDPZfpj0RsVgsFovFYrFYLBaLxWKxWCwWi8VisVg8j+IFPRuGCFZP0vAAAAAASUVORK5CYII=',
|
||||||
|
);
|
||||||
|
bool isWebp(List<int> bytes) =>
|
||||||
|
String.fromCharCodes(bytes.sublist(0, 4)) == 'RIFF' &&
|
||||||
|
String.fromCharCodes(bytes.sublist(8, 12)) == 'WEBP';
|
||||||
|
|
||||||
|
// 1. A picture uploaded on the website reaches both tablets, as WebP
|
||||||
|
final shotName = 'E2E Shot $suffix';
|
||||||
|
final shot = await website.createProduct({
|
||||||
|
'name': shotName,
|
||||||
|
'priceInCents': 300,
|
||||||
|
'stock': 10,
|
||||||
|
});
|
||||||
|
final uploaded = await website.uploadImage(
|
||||||
|
shot['productId'] as String,
|
||||||
|
png,
|
||||||
|
);
|
||||||
|
expect(uploaded['imageId'], matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||||
|
for (final tablet in [a, b]) {
|
||||||
|
await tablet.syncWithPictures();
|
||||||
|
final product = await tablet.product(shotName);
|
||||||
|
expect(product.imagePath, endsWith('${uploaded['imageId']}.webp'));
|
||||||
|
expect(isWebp(File(product.imagePath!).readAsBytesSync()), isTrue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. A product made on tablet A, with a photo, reaches the website and
|
||||||
|
// tablet B with the server's picture
|
||||||
|
final drinkName = 'E2E Tablet Drink $suffix';
|
||||||
|
final photo = File(path.join(a.pictures.path, 'photo.png'))
|
||||||
|
..writeAsBytesSync(png);
|
||||||
|
await a.products.createProduct(
|
||||||
|
name: drinkName,
|
||||||
|
category: 'Frisdrank',
|
||||||
|
stockQuantity: 8,
|
||||||
|
lowStockThreshold: 2,
|
||||||
|
priceInCents: 220,
|
||||||
|
imagePath: photo.path,
|
||||||
|
);
|
||||||
|
// The tile shows the photo at once, before any sync
|
||||||
|
expect((await a.product(drinkName)).imagePath, photo.path);
|
||||||
|
await a.syncWithPictures();
|
||||||
|
final onWebsite = (await website.products()).singleWhere(
|
||||||
|
(p) => p['name'] == drinkName,
|
||||||
|
);
|
||||||
|
expect(onWebsite, containsPair('stock', 8));
|
||||||
|
expect(onWebsite['imageId'], isNotNull);
|
||||||
|
await b.syncWithPictures();
|
||||||
|
final onB = await b.product(drinkName);
|
||||||
|
expect(onB.priceInCents, 220);
|
||||||
|
expect(onB.imagePath, endsWith('${onWebsite['imageId']}.webp'));
|
||||||
|
// Tablet A now shows the server's cropped copy as well
|
||||||
|
expect(
|
||||||
|
(await a.product(drinkName)).imagePath,
|
||||||
|
endsWith('${onWebsite['imageId']}.webp'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Tablet A renames it offline while the website changes its price:
|
||||||
|
// both survive
|
||||||
|
final drinkId = onWebsite['productId'] as String;
|
||||||
|
final renamed = 'E2E Renamed $suffix';
|
||||||
|
await a.products.updateProduct(
|
||||||
|
(await a.product(drinkName)).copyWith(name: renamed),
|
||||||
|
);
|
||||||
|
await website.changeProduct(drinkId, {'priceInCents': 240});
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow();
|
||||||
|
for (final tablet in [a, b]) {
|
||||||
|
final product = await tablet.product(renamed);
|
||||||
|
expect(product.priceInCents, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Deleted on tablet A: archived for the website and tablet B
|
||||||
|
await a.products.deleteProduct(drinkId);
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow();
|
||||||
|
expect(
|
||||||
|
(await b.products.getProducts()).where((p) => p.id == drinkId),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await website.products()).singleWhere(
|
||||||
|
(p) => p['productId'] == drinkId,
|
||||||
|
)['archivedAt'],
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leave the venue as it was
|
||||||
|
await website.changeProduct(shot['productId'] as String, {
|
||||||
|
'archived': true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
skip: _skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:kooltap/database/app_database.dart';
|
||||||
|
import 'package:kooltap/database/catalog_queries.dart';
|
||||||
|
import 'package:kooltap/services/kooltap_api_client.dart';
|
||||||
|
import 'package:kooltap/services/product_image_sync.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
const _picture = [1, 2, 3, 4];
|
||||||
|
final _imageId = 'a' * 64;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase database;
|
||||||
|
late Directory directory;
|
||||||
|
late ProductImageSync images;
|
||||||
|
late List<http.Request> requests;
|
||||||
|
late Future<http.Response> Function(http.Request) respond;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
database = AppDatabase(NativeDatabase.memory());
|
||||||
|
directory = await Directory.systemTemp.createTemp('kooltap_images_');
|
||||||
|
requests = [];
|
||||||
|
respond = (request) async {
|
||||||
|
if (request.url.path.endsWith('/images') && request.method == 'POST') {
|
||||||
|
return http.Response(jsonEncode({'imageId': _imageId}), 201);
|
||||||
|
}
|
||||||
|
if (request.url.path.endsWith('/images/$_imageId')) {
|
||||||
|
return http.Response.bytes(_picture, 200);
|
||||||
|
}
|
||||||
|
return http.Response('not found', 404);
|
||||||
|
};
|
||||||
|
images = ProductImageSync(
|
||||||
|
database: database,
|
||||||
|
apiClient: KoolTapApiClient(
|
||||||
|
client: MockClient((request) {
|
||||||
|
requests.add(request);
|
||||||
|
return respond(request);
|
||||||
|
}),
|
||||||
|
baseUrl: 'https://api.test',
|
||||||
|
),
|
||||||
|
imagesDirectory: () async => directory,
|
||||||
|
);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.into(database.products)
|
||||||
|
.insert(
|
||||||
|
ProductsCompanion.insert(
|
||||||
|
id: 'cola',
|
||||||
|
name: 'Cola',
|
||||||
|
category: 'Frisdrank',
|
||||||
|
stockQuantity: 10,
|
||||||
|
lowStockThreshold: 2,
|
||||||
|
priceInCents: 250,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await database.close();
|
||||||
|
await directory.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<ProductRow> cola() => (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals('cola'))).getSingle();
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> outbox() async => [
|
||||||
|
for (final row in await database.select(database.tabEvents).get())
|
||||||
|
jsonDecode(row.payload) as Map<String, dynamic>,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<String> takePicture(String name) async {
|
||||||
|
final file = File(path.join(directory.path, name));
|
||||||
|
await file.writeAsBytes([9, 9, 9]);
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('uploadPending', () {
|
||||||
|
test(
|
||||||
|
'uploads a picture taken here and links it for every tablet',
|
||||||
|
() async {
|
||||||
|
final picture = await takePicture('taken.jpg');
|
||||||
|
await database.queueImageUpload('cola', picture);
|
||||||
|
|
||||||
|
await images.uploadPending(token: 'token', deviceId: 'd1');
|
||||||
|
|
||||||
|
expect(requests.single.url.path, '/devices/d1/images');
|
||||||
|
expect(requests.single.headers['Authorization'], 'Bearer token');
|
||||||
|
expect(
|
||||||
|
(await outbox()).single,
|
||||||
|
containsPair('type', 'product_updated'),
|
||||||
|
);
|
||||||
|
expect((await outbox()).single['product'], {'imageId': _imageId});
|
||||||
|
expect((await cola()).imageId, _imageId);
|
||||||
|
// The tile keeps the picture it shows until the server's copy is here
|
||||||
|
expect(
|
||||||
|
await database.select(database.pendingImageUploads).get(),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('offline, the picture waits for the next sync', () async {
|
||||||
|
respond = (_) async => throw http.ClientException('Failed host lookup');
|
||||||
|
await database.queueImageUpload('cola', await takePicture('taken.jpg'));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
images.uploadPending(token: 'token', deviceId: 'd1'),
|
||||||
|
throwsA(isA<http.ClientException>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await database.select(database.pendingImageUploads).get(),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(await outbox(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a picture the server cannot read is dropped instead of blocking the queue',
|
||||||
|
() async {
|
||||||
|
respond = (_) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'error': {'code': 'invalid_image', 'message': 'Not an image'},
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
await database.queueImageUpload(
|
||||||
|
'cola',
|
||||||
|
await takePicture('broken.jpg'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await images.uploadPending(token: 'token', deviceId: 'd1');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await database.select(database.pendingImageUploads).get(),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
expect(await outbox(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a picture whose file is gone is forgotten', () async {
|
||||||
|
await database.queueImageUpload(
|
||||||
|
'cola',
|
||||||
|
path.join(directory.path, 'gone.jpg'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await images.uploadPending(token: 'token', deviceId: 'd1');
|
||||||
|
|
||||||
|
expect(requests, isEmpty);
|
||||||
|
expect(
|
||||||
|
await database.select(database.pendingImageUploads).get(),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a picture replaced while uploading is not linked; the newer one goes next',
|
||||||
|
() async {
|
||||||
|
final first = await takePicture('first.jpg');
|
||||||
|
final second = await takePicture('second.jpg');
|
||||||
|
await database.queueImageUpload('cola', first);
|
||||||
|
respond = (request) async {
|
||||||
|
// Another picture is taken while this one uploads
|
||||||
|
await database.queueImageUpload('cola', second);
|
||||||
|
return http.Response(jsonEncode({'imageId': _imageId}), 201);
|
||||||
|
};
|
||||||
|
|
||||||
|
await images.uploadPending(token: 'token', deviceId: 'd1');
|
||||||
|
|
||||||
|
expect(await outbox(), isEmpty);
|
||||||
|
expect(
|
||||||
|
(await database.select(database.pendingImageUploads).getSingle())
|
||||||
|
.localPath,
|
||||||
|
second,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('downloadMissing', () {
|
||||||
|
Future<void> setImage(String? imageId, {String? imagePath}) async {
|
||||||
|
await (database.update(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals('cola'))).write(
|
||||||
|
ProductsCompanion(imageId: Value(imageId), imagePath: Value(imagePath)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('downloads a new picture and shows it', () async {
|
||||||
|
await setImage(_imageId);
|
||||||
|
|
||||||
|
await images.downloadMissing();
|
||||||
|
|
||||||
|
final target = path.join(directory.path, '$_imageId.webp');
|
||||||
|
expect((await cola()).imagePath, target);
|
||||||
|
expect(await File(target).readAsBytes(), _picture);
|
||||||
|
expect(File('$target.part').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads a picture once', () async {
|
||||||
|
await setImage(_imageId);
|
||||||
|
|
||||||
|
await images.downloadMissing();
|
||||||
|
await images.downloadMissing();
|
||||||
|
|
||||||
|
expect(requests, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'offline, keeps showing the old picture and tries again later',
|
||||||
|
() async {
|
||||||
|
final old = await takePicture('old.webp');
|
||||||
|
await setImage(_imageId, imagePath: old);
|
||||||
|
respond = (_) async => throw http.ClientException('Failed host lookup');
|
||||||
|
|
||||||
|
await images.downloadMissing();
|
||||||
|
expect((await cola()).imagePath, old);
|
||||||
|
|
||||||
|
respond = (_) async => http.Response.bytes(_picture, 200);
|
||||||
|
await images.downloadMissing();
|
||||||
|
expect(
|
||||||
|
(await cola()).imagePath,
|
||||||
|
path.join(directory.path, '$_imageId.webp'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'leaves a product alone while its own picture waits for upload',
|
||||||
|
() async {
|
||||||
|
final taken = await takePicture('taken.jpg');
|
||||||
|
await setImage(_imageId, imagePath: taken);
|
||||||
|
await database.queueImageUpload('cola', taken);
|
||||||
|
|
||||||
|
await images.downloadMissing();
|
||||||
|
|
||||||
|
expect(requests, isEmpty);
|
||||||
|
expect((await cola()).imagePath, taken);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'deletes old pictures nothing uses, keeps ones in use and just picked ones',
|
||||||
|
() async {
|
||||||
|
final unused = File(path.join(directory.path, 'unused.webp'));
|
||||||
|
await unused.writeAsBytes([1]);
|
||||||
|
await unused.setLastModified(
|
||||||
|
DateTime.now().subtract(const Duration(hours: 2)),
|
||||||
|
);
|
||||||
|
final justPicked = await takePicture('just_picked.jpg');
|
||||||
|
final shown = await takePicture('shown.webp');
|
||||||
|
await File(
|
||||||
|
shown,
|
||||||
|
).setLastModified(DateTime.now().subtract(const Duration(hours: 2)));
|
||||||
|
await setImage(null, imagePath: shown);
|
||||||
|
|
||||||
|
await images.downloadMissing();
|
||||||
|
|
||||||
|
expect(unused.existsSync(), isFalse);
|
||||||
|
expect(File(justPicked).existsSync(), isTrue);
|
||||||
|
expect(File(shown).existsSync(), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
@@ -11,8 +12,10 @@ import 'package:kooltap/models/product.dart';
|
|||||||
import 'package:kooltap/models/remote_command.dart';
|
import 'package:kooltap/models/remote_command.dart';
|
||||||
import 'package:kooltap/services/bar_tab_service.dart';
|
import 'package:kooltap/services/bar_tab_service.dart';
|
||||||
import 'package:kooltap/services/kooltap_api_client.dart';
|
import 'package:kooltap/services/kooltap_api_client.dart';
|
||||||
|
import 'package:kooltap/services/product_image_sync.dart';
|
||||||
import 'package:kooltap/services/product_service.dart';
|
import 'package:kooltap/services/product_service.dart';
|
||||||
import 'package:kooltap/services/remote_sync_service.dart';
|
import 'package:kooltap/services/remote_sync_service.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
import 'helpers/memory_secure_storage.dart';
|
import 'helpers/memory_secure_storage.dart';
|
||||||
|
|
||||||
@@ -36,15 +39,27 @@ class _FakeServer {
|
|||||||
/// While set, `/sync` responses wait for it to complete.
|
/// While set, `/sync` responses wait for it to complete.
|
||||||
Completer<void>? holdSync;
|
Completer<void>? holdSync;
|
||||||
|
|
||||||
late final client = MockClient((request) async {
|
/// Pictures uploaded by terminals, by the id the server gave them.
|
||||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
final Map<String, int> uploadedImages = {};
|
||||||
|
|
||||||
if (request.url.path.endsWith('/sync')) {
|
late final client = MockClient((request) async {
|
||||||
|
final path = request.url.path;
|
||||||
|
if (path.endsWith('/images') && request.method == 'POST') {
|
||||||
|
final imageId = 'img${uploadedImages.length + 1}'.padRight(64, '0');
|
||||||
|
uploadedImages[imageId] = request.bodyBytes.length;
|
||||||
|
return http.Response(jsonEncode({'imageId': imageId}), 201);
|
||||||
|
}
|
||||||
|
if (path.contains('/images/')) {
|
||||||
|
return http.Response.bytes([1, 2, 3], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
if (path.endsWith('/sync')) {
|
||||||
syncRequests.add(body);
|
syncRequests.add(body);
|
||||||
await holdSync?.future;
|
await holdSync?.future;
|
||||||
return syncResponse;
|
return syncResponse;
|
||||||
}
|
}
|
||||||
if (request.url.path.endsWith('/ack')) {
|
if (path.endsWith('/ack')) {
|
||||||
acks.add(body);
|
acks.add(body);
|
||||||
return http.Response('', 200);
|
return http.Response('', 200);
|
||||||
}
|
}
|
||||||
@@ -1460,7 +1475,8 @@ void main() {
|
|||||||
.having((p) => p.priceInCents, 'price', 450)
|
.having((p) => p.priceInCents, 'price', 450)
|
||||||
.having((p) => p.category, 'category', '')
|
.having((p) => p.category, 'category', '')
|
||||||
.having((p) => p.lowStockThreshold, 'threshold', 6)
|
.having((p) => p.lowStockThreshold, 'threshold', 6)
|
||||||
.having((p) => p.imagePath, 'image', '/beer.png')
|
// The website has no picture: the old local one is dropped
|
||||||
|
.having((p) => p.imagePath, 'image', isNull)
|
||||||
.having((p) => p.active, 'active', isTrue),
|
.having((p) => p.active, 'active', isTrue),
|
||||||
);
|
);
|
||||||
// New, and its stock arrives in the same sync
|
// New, and its stock arrives in the same sync
|
||||||
@@ -1764,6 +1780,224 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('products edited on this tablet', () {
|
||||||
|
late DriftProductService products;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
products = DriftProductService(database: database);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> beer({
|
||||||
|
String name = 'Beer',
|
||||||
|
int price = 400,
|
||||||
|
bool archived = false,
|
||||||
|
String? imageId,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'id': 'prod-1',
|
||||||
|
'name': name,
|
||||||
|
'category': 'Drinks',
|
||||||
|
'priceInCents': price,
|
||||||
|
'lowStockThreshold': 10,
|
||||||
|
'archived': archived,
|
||||||
|
'imageId': imageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProductRow?> row(String id) => (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals(id))).getSingleOrNull();
|
||||||
|
|
||||||
|
test('an offline rename stays until the server has it', () async {
|
||||||
|
await products.updateProduct(_beer.copyWith(name: 'Pils'));
|
||||||
|
// The server hasn't seen the rename yet: its catalog still says Beer
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
expect((await row('prod-1'))!.name, 'Pils');
|
||||||
|
|
||||||
|
// Once acknowledged, the server's catalog has it
|
||||||
|
server.respondWith({
|
||||||
|
'acknowledgedEventIds': [
|
||||||
|
for (final event in server.syncRequests.last['events'] as List)
|
||||||
|
event['id'],
|
||||||
|
],
|
||||||
|
'catalog': [beer(name: 'Pils')],
|
||||||
|
});
|
||||||
|
await service.sync();
|
||||||
|
expect((await row('prod-1'))!.name, 'Pils');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a product created offline stays on the bar screen', () async {
|
||||||
|
await products.createProduct(
|
||||||
|
name: 'Ice Coffee',
|
||||||
|
category: 'Koffie',
|
||||||
|
stockQuantity: 12,
|
||||||
|
lowStockThreshold: 3,
|
||||||
|
priceInCents: 350,
|
||||||
|
imagePath: null,
|
||||||
|
);
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await products.getProducts()).map((p) => p.name),
|
||||||
|
containsAll(['Beer', 'Ice Coffee']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a product deleted offline stays hidden', () async {
|
||||||
|
await products.deleteProduct('prod-1');
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
|
||||||
|
expect(await products.getProducts(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a website change of another field is kept next to the offline one',
|
||||||
|
() async {
|
||||||
|
await products.updateProduct(_beer.copyWith(name: 'Pils'));
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer(price: 450)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
|
||||||
|
final pils = (await row('prod-1'))!;
|
||||||
|
expect(pils.name, 'Pils');
|
||||||
|
expect(pils.priceInCents, 450);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a changed picture keeps the old one until downloaded', () async {
|
||||||
|
await (database.update(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals('prod-1'))).write(
|
||||||
|
ProductsCompanion(
|
||||||
|
imageId: Value('o' * 64),
|
||||||
|
imagePath: const Value('/pictures/old.webp'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer(imageId: 'n' * 64)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
|
||||||
|
final row1 = (await row('prod-1'))!;
|
||||||
|
expect(row1.imageId, 'n' * 64);
|
||||||
|
expect(row1.imagePath, '/pictures/old.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a picture waiting for upload is kept, also when the website has none',
|
||||||
|
() async {
|
||||||
|
await products.updateProduct(
|
||||||
|
_beer.copyWith(imagePath: '/pictures/taken.jpg'),
|
||||||
|
);
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [beer()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync();
|
||||||
|
|
||||||
|
expect((await row('prod-1'))!.imagePath, '/pictures/taken.jpg');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('pictures through the sync', () {
|
||||||
|
late Directory directory;
|
||||||
|
late RemoteSyncService withPictures;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
directory = await Directory.systemTemp.createTemp('kooltap_sync_images_');
|
||||||
|
final api = KoolTapApiClient(client: server.client, baseUrl: _baseUrl);
|
||||||
|
withPictures = RemoteSyncService(
|
||||||
|
database: database,
|
||||||
|
barTabService: barTabService,
|
||||||
|
apiClient: api,
|
||||||
|
secureStorage: storage,
|
||||||
|
imageSync: ProductImageSync(
|
||||||
|
database: database,
|
||||||
|
apiClient: api,
|
||||||
|
imagesDirectory: () async => directory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
withPictures.dispose();
|
||||||
|
await directory.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a picture taken here is uploaded and linked in the same sync',
|
||||||
|
() async {
|
||||||
|
final taken = File(path.join(directory.path, 'taken.jpg'));
|
||||||
|
await taken.writeAsBytes([9, 9, 9]);
|
||||||
|
await DriftProductService(
|
||||||
|
database: database,
|
||||||
|
).updateProduct(_beer.copyWith(imagePath: taken.path));
|
||||||
|
|
||||||
|
await withPictures.sync();
|
||||||
|
|
||||||
|
final imageId = server.uploadedImages.keys.single;
|
||||||
|
expect(
|
||||||
|
server.syncRequests.single['events'],
|
||||||
|
contains(
|
||||||
|
allOf(
|
||||||
|
containsPair('type', 'product_updated'),
|
||||||
|
containsPair('product', {'imageId': imageId}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the server's picture is downloaded after the sync", () async {
|
||||||
|
final imageId = 'f' * 64;
|
||||||
|
server.respondWith({
|
||||||
|
'catalog': [
|
||||||
|
{
|
||||||
|
'id': 'prod-1',
|
||||||
|
'name': 'Beer',
|
||||||
|
'category': 'Drinks',
|
||||||
|
'priceInCents': 400,
|
||||||
|
'lowStockThreshold': 10,
|
||||||
|
'archived': false,
|
||||||
|
'imageId': imageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await withPictures.sync();
|
||||||
|
// The download runs in the background
|
||||||
|
for (var i = 0; i < 50; i++) {
|
||||||
|
final row = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||||
|
if (row.imagePath != null) break;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
final beer = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||||
|
expect(beer.imagePath, path.join(directory.path, '$imageId.webp'));
|
||||||
|
expect(File(beer.imagePath!).readAsBytesSync(), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('a part of the response that fails', () {
|
group('a part of the response that fails', () {
|
||||||
Map<String, dynamic> brokenTab() {
|
Map<String, dynamic> brokenTab() {
|
||||||
// Two lines claiming the same unit: the database refuses the second
|
// Two lines claiming the same unit: the database refuses the second
|
||||||
|
|||||||
+175
-13
@@ -433,6 +433,159 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('product events', () {
|
||||||
|
late DriftProductService products;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
products = DriftProductService(
|
||||||
|
database: database,
|
||||||
|
eventFactory: TabEventFactory(
|
||||||
|
now: () => DateTime.utc(2026, 10, 24, 15, 3, 40),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> productEvents() async => [
|
||||||
|
for (final event in await outbox())
|
||||||
|
if ((event['type'] as String).startsWith('product_')) event,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<List<PendingImageUploadRow>> uploads() =>
|
||||||
|
database.select(database.pendingImageUploads).get();
|
||||||
|
|
||||||
|
test('a new product goes out with its fields, then its stock', () async {
|
||||||
|
await products.createProduct(
|
||||||
|
name: 'Ice Coffee',
|
||||||
|
category: 'Koffie',
|
||||||
|
stockQuantity: 12,
|
||||||
|
lowStockThreshold: 3,
|
||||||
|
priceInCents: 350,
|
||||||
|
imagePath: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final id = (await products.getProducts())
|
||||||
|
.firstWhere((p) => p.name == 'Ice Coffee')
|
||||||
|
.id;
|
||||||
|
final events = await outbox();
|
||||||
|
expect(events.map((e) => e['type']), ['product_created', 'stock_set']);
|
||||||
|
expect(events.first, {
|
||||||
|
'id': events.first['id'],
|
||||||
|
'type': 'product_created',
|
||||||
|
'productId': id,
|
||||||
|
'product': {
|
||||||
|
'name': 'Ice Coffee',
|
||||||
|
'category': 'Koffie',
|
||||||
|
'priceInCents': 350,
|
||||||
|
'lowStockThreshold': 3,
|
||||||
|
},
|
||||||
|
'occurredAt': '2026-10-24T15:03:40.000Z',
|
||||||
|
});
|
||||||
|
expect(events.first.containsKey('tabId'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'an untracked new product has no count, and no category is null',
|
||||||
|
() async {
|
||||||
|
await products.createProduct(
|
||||||
|
name: 'Tap water',
|
||||||
|
category: ' ',
|
||||||
|
stockQuantity: 0,
|
||||||
|
lowStockThreshold: 0,
|
||||||
|
infiniteStock: true,
|
||||||
|
priceInCents: 0,
|
||||||
|
imagePath: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final events = await outbox();
|
||||||
|
expect(events.map((e) => e['type']), ['product_created']);
|
||||||
|
expect(events.single['product'], containsPair('category', null));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a change sends only the fields that changed', () async {
|
||||||
|
final cola = (await products.getProductById('prod-1'))!;
|
||||||
|
|
||||||
|
await products.updateProduct(
|
||||||
|
cola.copyWith(name: 'Coca-Cola', priceInCents: 270),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await productEvents()).single,
|
||||||
|
containsPair('product', {'name': 'Coca-Cola', 'priceInCents': 270}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving without changes sends nothing', () async {
|
||||||
|
final cola = (await products.getProductById('prod-1'))!;
|
||||||
|
|
||||||
|
await products.updateProduct(cola);
|
||||||
|
|
||||||
|
expect(await outbox(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting archives it for every tablet', () async {
|
||||||
|
await products.deleteProduct('prod-1');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await productEvents()).single,
|
||||||
|
containsPair('type', 'product_archived'),
|
||||||
|
);
|
||||||
|
expect(await products.getProducts(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a picked picture waits for upload, a newer one replaces it',
|
||||||
|
() async {
|
||||||
|
final cola = (await products.getProductById('prod-1'))!;
|
||||||
|
|
||||||
|
await products.updateProduct(
|
||||||
|
cola.copyWith(imagePath: '/pictures/a.jpg'),
|
||||||
|
);
|
||||||
|
await products.updateProduct(
|
||||||
|
cola.copyWith(imagePath: '/pictures/b.jpg'),
|
||||||
|
);
|
||||||
|
// Saving again with the same picture uploads nothing new
|
||||||
|
await products.updateProduct(
|
||||||
|
cola.copyWith(imagePath: '/pictures/b.jpg', name: 'Coke'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final waiting = await uploads();
|
||||||
|
expect(waiting.single.productId, 'prod-1');
|
||||||
|
expect(waiting.single.localPath, '/pictures/b.jpg');
|
||||||
|
// The picture itself is linked once uploaded, not now
|
||||||
|
expect(
|
||||||
|
(await productEvents()).where(
|
||||||
|
(e) => (e['product'] as Map?)?.containsKey('imageId') ?? false,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a new product with a picture queues it for upload', () async {
|
||||||
|
await products.createProduct(
|
||||||
|
name: 'Ice Coffee',
|
||||||
|
category: 'Koffie',
|
||||||
|
stockQuantity: 12,
|
||||||
|
lowStockThreshold: 3,
|
||||||
|
priceInCents: 350,
|
||||||
|
imagePath: '/pictures/ice.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await uploads()).single.localPath, '/pictures/ice.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('are skipped when replaying the outbox onto server tabs', () async {
|
||||||
|
final tab = await openTab('Jan');
|
||||||
|
await products.deleteProduct('prod-1');
|
||||||
|
|
||||||
|
await service.applyServerState([]);
|
||||||
|
|
||||||
|
expect(await service.getTabById(tab.id), isNull);
|
||||||
|
expect(await productEvents(), hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('stock_set events', () {
|
group('stock_set events', () {
|
||||||
late DriftProductService products;
|
late DriftProductService products;
|
||||||
|
|
||||||
@@ -481,27 +634,36 @@ void main() {
|
|||||||
final water = (await products.getProducts()).firstWhere(
|
final water = (await products.getProducts()).firstWhere(
|
||||||
(product) => product.name == 'Water',
|
(product) => product.name == 'Water',
|
||||||
);
|
);
|
||||||
final events = await outbox();
|
final counts = [
|
||||||
expect(events, hasLength(1));
|
for (final event in await outbox())
|
||||||
expect(events.single, containsPair('type', 'stock_set'));
|
if (event['type'] == 'stock_set') event,
|
||||||
expect(events.single, containsPair('productId', water.id));
|
];
|
||||||
expect(events.single, containsPair('quantity', 24));
|
expect(counts, hasLength(1));
|
||||||
|
expect(counts.single, containsPair('productId', water.id));
|
||||||
|
expect(counts.single, containsPair('quantity', 24));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a stock or tracking change queues a count', () async {
|
test('a stock or tracking change queues a count', () async {
|
||||||
final cola = (await products.getProductById('prod-1'))!;
|
final cola = (await products.getProductById('prod-1'))!;
|
||||||
|
|
||||||
await products.updateProduct(cola.copyWith(name: 'Coca-Cola'));
|
final renamed = cola.copyWith(name: 'Coca-Cola');
|
||||||
expect(await outbox(), isEmpty);
|
await products.updateProduct(renamed);
|
||||||
|
expect(
|
||||||
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
(await outbox()).where((event) => event['type'] == 'stock_set'),
|
||||||
await products.updateProduct(
|
isEmpty,
|
||||||
cola.copyWith(stockQuantity: 40, infiniteStock: true),
|
|
||||||
);
|
);
|
||||||
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
|
||||||
|
await products.updateProduct(renamed.copyWith(stockQuantity: 40));
|
||||||
|
await products.updateProduct(
|
||||||
|
renamed.copyWith(stockQuantity: 40, infiniteStock: true),
|
||||||
|
);
|
||||||
|
await products.updateProduct(renamed.copyWith(stockQuantity: 40));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
[for (final event in await outbox()) event['quantity']],
|
[
|
||||||
|
for (final event in await outbox())
|
||||||
|
if (event['type'] == 'stock_set') event['quantity'],
|
||||||
|
],
|
||||||
[40, null, 40],
|
[40, null, 40],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user