Compare commits
3 commits
2884ddd3c7
...
071be44851
| Author | SHA1 | Date | |
|---|---|---|---|
| 071be44851 | |||
| f17ae7751c | |||
| 3de01bd948 |
38 changed files with 5859 additions and 98 deletions
|
|
@ -73,6 +73,17 @@ android {
|
||||||
} else {
|
} else {
|
||||||
signingConfigs.getByName("debug")
|
signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
// R8: shrink + optimize + obfuscate the Java/Kotlin bytecode and strip
|
||||||
|
// unused resources — Play's "app optimization" recommendation (smaller
|
||||||
|
// download, less memory). Native .so libs are untouched, so this does
|
||||||
|
// not change device/ABI compatibility. Keep rules for reflection/JNI
|
||||||
|
// heavy deps (OCR, SQLCipher, notifications) live in proguard-rules.pro.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
apps/app_seeds/android/app/proguard-rules.pro
vendored
Normal file
40
apps/app_seeds/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# R8/ProGuard keep rules for Tane (org.comunes.tane).
|
||||||
|
#
|
||||||
|
# R8 is enabled for release builds (see build.gradle.kts). It shrinks/optimizes
|
||||||
|
# the Java/Kotlin bytecode and can break code reached only via reflection or JNI
|
||||||
|
# — the native plugins below load their Java classes from C, so R8 can't see the
|
||||||
|
# references and would strip/rename them. Keep them explicitly. Start
|
||||||
|
# conservative; trim only after a release smoke test proves a class is unused.
|
||||||
|
|
||||||
|
# --- Flutter engine & embedding (defensive; usually handled by the plugin) ---
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-dontwarn io.flutter.embedding.**
|
||||||
|
|
||||||
|
# --- On-device OCR: tesseract4android (JNI) ---
|
||||||
|
# libtesseract/libleptonica call back into these Java classes by name.
|
||||||
|
-keep class com.googlecode.tesseract.android.** { *; }
|
||||||
|
-keep class com.googlecode.leptonica.android.** { *; }
|
||||||
|
-keep class io.paratoner.flutter_tesseract_ocr.** { *; }
|
||||||
|
|
||||||
|
# --- QR scanning: zxing_barcode_scanner (pure ZXing, platform view) ---
|
||||||
|
-keep class com.shirisharyal.zxing_barcode_scanner.** { *; }
|
||||||
|
|
||||||
|
# --- Encrypted DB: SQLCipher / sqlite3 native loader ---
|
||||||
|
# sqlite3 is reached over FFI (dlopen), but keep the loader plugin classes.
|
||||||
|
-keep class eu.simonbinder.sqlite3_flutter_libs.** { *; }
|
||||||
|
-keep class net.zetetic.** { *; }
|
||||||
|
-dontwarn net.zetetic.**
|
||||||
|
|
||||||
|
# --- Local notifications ---
|
||||||
|
# Published keep rules for flutter_local_notifications' Gson-serialized models.
|
||||||
|
-keep class com.dexterous.** { *; }
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keep class * extends com.google.gson.TypeAdapter
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
# --- Core library desugaring ---
|
||||||
|
-dontwarn java.lang.invoke.**
|
||||||
|
-dontwarn build.IgnoreJava8API
|
||||||
|
|
@ -5,6 +5,19 @@
|
||||||
<!-- Foreground local notifications for incoming private messages (Android 13+
|
<!-- Foreground local notifications for incoming private messages (Android 13+
|
||||||
asks the user at runtime). -->
|
asks the user at runtime). -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- The CAMERA permission (pulled in by zxing_barcode_scanner for QR scanning
|
||||||
|
and used by image_picker) makes Android implicitly require the camera
|
||||||
|
hardware features, which would exclude camera-less devices — Chromebooks,
|
||||||
|
Android Automotive, many TVs — from Play. The app degrades gracefully
|
||||||
|
without a camera (gallery import still works; the scan button hides), so
|
||||||
|
declare these optional to keep those devices supported. Same for the
|
||||||
|
location features implied by ACCESS_COARSE_LOCATION. -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.location" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.location.network" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
<application
|
<application
|
||||||
android:label="Tane"
|
android:label="Tane"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class MainActivity : FlutterActivity() {
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getCoarseLatLon" -> getCoarseLatLon(result)
|
"getCoarseLatLon" -> getCoarseLatLon(result)
|
||||||
|
"hasCamera" -> result.success(hasCameraHardware())
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +73,15 @@ class MainActivity : FlutterActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device has any camera. Camera-less devices (Chromebooks,
|
||||||
|
* Android Automotive, many TVs) are supported — see AndroidManifest's
|
||||||
|
* uses-feature required="false" — so the QR scan and camera-capture UI
|
||||||
|
* hide themselves when this is false instead of offering broken actions.
|
||||||
|
*/
|
||||||
|
private fun hasCameraHardware(): Boolean =
|
||||||
|
packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
|
||||||
|
|
||||||
private fun hasLocationPermission(): Boolean =
|
private fun hasLocationPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
this,
|
this,
|
||||||
|
|
|
||||||
2509
apps/app_seeds/drift_schemas/drift_schema_v14.json
Normal file
2509
apps/app_seeds/drift_schemas/drift_schema_v14.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -16,13 +16,24 @@ default_platform(:android)
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Upload the signed AAB + store listing to Google Play (internal track)"
|
desc "Upload the signed AAB + store listing to Google Play (production, 100%)"
|
||||||
lane :deploy_play do
|
lane :deploy_play do
|
||||||
|
supply(
|
||||||
|
track: "production",
|
||||||
|
aab: AAB_PATH,
|
||||||
|
release_status: "completed", # publish to 100% of users (subject to review)
|
||||||
|
skip_upload_apk: true, # we ship the AAB, not a raw APK
|
||||||
|
skip_upload_changelogs: false,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Upload the signed AAB to the internal test track (manual QA safety net)"
|
||||||
|
lane :deploy_internal do
|
||||||
supply(
|
supply(
|
||||||
track: "internal",
|
track: "internal",
|
||||||
aab: AAB_PATH,
|
aab: AAB_PATH,
|
||||||
release_status: "completed", # internal testing has no review delay
|
release_status: "completed", # internal testing has no review delay
|
||||||
skip_upload_apk: true, # we ship the AAB, not a raw APK
|
skip_upload_apk: true,
|
||||||
skip_upload_changelogs: false,
|
skip_upload_changelogs: false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'data/variety_repository.dart';
|
||||||
import 'di/injector.dart';
|
import 'di/injector.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'services/auto_backup_service.dart';
|
import 'services/auto_backup_service.dart';
|
||||||
|
import 'services/camera_availability.dart';
|
||||||
import 'services/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
import 'services/inbox_service.dart';
|
import 'services/inbox_service.dart';
|
||||||
import 'services/locale_store.dart';
|
import 'services/locale_store.dart';
|
||||||
|
|
@ -50,6 +51,11 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
Future<TaneApp> _boot() async {
|
Future<TaneApp> _boot() async {
|
||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
|
|
||||||
|
// Detect camera presence once, before the home screen builds, so camera-less
|
||||||
|
// devices (Chromebooks, Automotive, some TVs) hide the QR scan / camera UI
|
||||||
|
// instead of offering broken actions. Non-Android platforms keep the default.
|
||||||
|
await initCameraAvailability();
|
||||||
|
|
||||||
// The saved language lives in the keystore, so it can only be applied once
|
// The saved language lives in the keystore, so it can only be applied once
|
||||||
// DI is up. Until now the splash showed in the device language (it has no
|
// DI is up. Until now the splash showed in the device language (it has no
|
||||||
// text), so there is no visible flip.
|
// text), so there is no visible flip.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
@ -652,13 +654,27 @@ class VarietyRepository {
|
||||||
required this.idGen,
|
required this.idGen,
|
||||||
required this.nodeId,
|
required this.nodeId,
|
||||||
int Function()? nowMillis,
|
int Function()? nowMillis,
|
||||||
|
Uint8List? Function(Uint8List)? thumbnailBuilder,
|
||||||
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
|
||||||
|
_thumbnailBuilder = thumbnailBuilder,
|
||||||
_clock = Hlc.zero(nodeId);
|
_clock = Hlc.zero(nodeId);
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final IdGen idGen;
|
final IdGen idGen;
|
||||||
final String nodeId;
|
final String nodeId;
|
||||||
final int Function() _now;
|
final int Function() _now;
|
||||||
|
|
||||||
|
/// Builds the small list-avatar thumbnail from a full photo. Injected (from
|
||||||
|
/// `services/offer_thumbnail.dart` in production) so the data layer stays free
|
||||||
|
/// of the image codec and unit tests run without decoding. Null → no
|
||||||
|
/// thumbnail is stored and the list falls back to the full photo.
|
||||||
|
final Uint8List? Function(Uint8List)? _thumbnailBuilder;
|
||||||
|
|
||||||
|
/// The thumbnail for [photoBytes], or null when no builder is wired or the
|
||||||
|
/// bytes aren't decodable. Wrapped in a Value for direct use in a companion.
|
||||||
|
Value<Uint8List?> _thumbValue(Uint8List photoBytes) =>
|
||||||
|
Value(_thumbnailBuilder?.call(photoBytes));
|
||||||
|
|
||||||
Hlc _clock;
|
Hlc _clock;
|
||||||
|
|
||||||
/// Emits the non-deleted inventory, ordered by category then label, each with
|
/// Emits the non-deleted inventory, ordered by category then label, each with
|
||||||
|
|
@ -696,7 +712,11 @@ class VarietyRepository {
|
||||||
_db.select(_db.species).watch().map((_) {}),
|
_db.select(_db.species).watch().map((_) {}),
|
||||||
_db.select(_db.lots).watch().map((_) {}),
|
_db.select(_db.lots).watch().map((_) {}),
|
||||||
]);
|
]);
|
||||||
return triggers.asyncMap(
|
// Coalesce bursts: a single quick-add or handover touches several of these
|
||||||
|
// tables at once, and each would otherwise re-run the full (7-query) load.
|
||||||
|
// Debouncing collapses the burst into one reload — decisive with a large
|
||||||
|
// inventory.
|
||||||
|
return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap(
|
||||||
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
|
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -929,7 +949,10 @@ class VarietyRepository {
|
||||||
return rows.map((l) => l.varietyId).toSet();
|
return rows.map((l) => l.varietyId).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the first photo BLOB for each of [varietyIds] (one query).
|
/// Loads the first photo's small [thumbnail] for each of [varietyIds] (one
|
||||||
|
/// query) — for the inventory-list avatar. Falls back to the full-resolution
|
||||||
|
/// [bytes] only when a thumbnail hasn't been generated yet (older rows,
|
||||||
|
/// pending lazy backfill), so the list stays correct meanwhile.
|
||||||
Future<Map<String, Uint8List>> _firstPhotosFor(
|
Future<Map<String, Uint8List>> _firstPhotosFor(
|
||||||
List<String> varietyIds,
|
List<String> varietyIds,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -950,17 +973,69 @@ class VarietyRepository {
|
||||||
.get();
|
.get();
|
||||||
final byVariety = <String, Uint8List>{};
|
final byVariety = <String, Uint8List>{};
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final bytes = row.bytes;
|
final image = row.thumbnail ?? row.bytes;
|
||||||
if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
|
if (image != null) byVariety.putIfAbsent(row.parentId, () => image);
|
||||||
}
|
}
|
||||||
return byVariety;
|
return byVariety;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the missing list thumbnails for photos that predate the
|
||||||
|
/// thumbnail column (or arrived via sync/backup restore, which never carry
|
||||||
|
/// one). Processes in small batches so decoding doesn't block the UI; safe to
|
||||||
|
/// call at startup as fire-and-forget. No-op when no thumbnail builder is
|
||||||
|
/// wired. Returns how many thumbnails were written.
|
||||||
|
Future<int> backfillThumbnails({int batchSize = 20}) async {
|
||||||
|
final build = _thumbnailBuilder;
|
||||||
|
if (build == null) return 0;
|
||||||
|
var written = 0;
|
||||||
|
while (true) {
|
||||||
|
final batch =
|
||||||
|
await (_db.select(_db.attachments)
|
||||||
|
..where(
|
||||||
|
(a) =>
|
||||||
|
a.kind.equalsValue(AttachmentKind.photo) &
|
||||||
|
a.isDeleted.equals(false) &
|
||||||
|
a.thumbnail.isNull() &
|
||||||
|
a.bytes.isNotNull(),
|
||||||
|
)
|
||||||
|
..limit(batchSize))
|
||||||
|
.get();
|
||||||
|
if (batch.isEmpty) break;
|
||||||
|
for (final row in batch) {
|
||||||
|
final thumb = build(row.bytes!);
|
||||||
|
// No decodable image → store the full bytes as the "thumbnail" so this
|
||||||
|
// row isn't re-scanned forever. It's rare (corrupt photo) and still
|
||||||
|
// bounded by the avatar's cacheWidth at render time.
|
||||||
|
await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id)))
|
||||||
|
.write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes)));
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
if (batch.length < batchSize) break;
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
/// The cover photo (lowest `sortOrder`) for a single variety, or null when it
|
/// The cover photo (lowest `sortOrder`) for a single variety, or null when it
|
||||||
/// has none. Used by the publish step to host an offer's image; reuses the
|
/// has none. Used by the publish step to host an offer's image, so it returns
|
||||||
/// same "first photo" rule as the inventory avatar.
|
/// the FULL-resolution [bytes] (not the list thumbnail).
|
||||||
Future<Uint8List?> coverPhotoFor(String varietyId) async =>
|
Future<Uint8List?> coverPhotoFor(String varietyId) async {
|
||||||
(await _firstPhotosFor([varietyId]))[varietyId];
|
final rows =
|
||||||
|
await (_db.select(_db.attachments)
|
||||||
|
..where(
|
||||||
|
(a) =>
|
||||||
|
a.parentId.equals(varietyId) &
|
||||||
|
a.parentType.equalsValue(ParentType.variety) &
|
||||||
|
a.kind.equalsValue(AttachmentKind.photo) &
|
||||||
|
a.isDeleted.equals(false),
|
||||||
|
)
|
||||||
|
..orderBy([
|
||||||
|
(a) => OrderingTerm(expression: a.sortOrder),
|
||||||
|
(a) => OrderingTerm(expression: a.createdAt),
|
||||||
|
])
|
||||||
|
..limit(1))
|
||||||
|
.get();
|
||||||
|
return rows.isEmpty ? null : rows.first.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
/// Maps each of [speciesIds] to its scientific name (one query).
|
/// Maps each of [speciesIds] to its scientific name (one query).
|
||||||
Future<Map<String, String>> _scientificNamesFor(
|
Future<Map<String, String>> _scientificNamesFor(
|
||||||
|
|
@ -1040,6 +1115,7 @@ class VarietyRepository {
|
||||||
parentId: varietyId,
|
parentId: varietyId,
|
||||||
kind: AttachmentKind.photo,
|
kind: AttachmentKind.photo,
|
||||||
bytes: Value(photoBytes),
|
bytes: Value(photoBytes),
|
||||||
|
thumbnail: _thumbValue(photoBytes),
|
||||||
mimeType: const Value('image/jpeg'),
|
mimeType: const Value('image/jpeg'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1080,6 +1156,7 @@ class VarietyRepository {
|
||||||
parentId: varietyId,
|
parentId: varietyId,
|
||||||
kind: AttachmentKind.photo,
|
kind: AttachmentKind.photo,
|
||||||
bytes: Value(photoBytes),
|
bytes: Value(photoBytes),
|
||||||
|
thumbnail: _thumbValue(photoBytes),
|
||||||
mimeType: const Value('image/jpeg'),
|
mimeType: const Value('image/jpeg'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1795,6 +1872,7 @@ class VarietyRepository {
|
||||||
parentId: varietyId,
|
parentId: varietyId,
|
||||||
kind: AttachmentKind.photo,
|
kind: AttachmentKind.photo,
|
||||||
bytes: Value(bytes),
|
bytes: Value(bytes),
|
||||||
|
thumbnail: _thumbValue(bytes),
|
||||||
mimeType: const Value('image/jpeg'),
|
mimeType: const Value('image/jpeg'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -3006,3 +3084,46 @@ class VarietyRepository {
|
||||||
return maxPacked == null ? null : Hlc.parse(maxPacked);
|
return maxPacked == null ? null : Hlc.parse(maxPacked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the latest event from [source] only once [duration] has elapsed with
|
||||||
|
/// no newer event — collapsing a burst of rapid change-triggers into a single
|
||||||
|
/// downstream reload. Trailing-edge; single-subscription (matches the merged
|
||||||
|
/// Drift trigger stream it wraps).
|
||||||
|
Stream<T> _debounce<T>(Stream<T> source, Duration duration) {
|
||||||
|
late StreamController<T> controller;
|
||||||
|
StreamSubscription<T>? sub;
|
||||||
|
Timer? timer;
|
||||||
|
T? pending;
|
||||||
|
var hasPending = false;
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
if (hasPending) {
|
||||||
|
hasPending = false;
|
||||||
|
controller.add(pending as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = StreamController<T>(
|
||||||
|
onListen: () {
|
||||||
|
sub = source.listen(
|
||||||
|
(value) {
|
||||||
|
pending = value;
|
||||||
|
hasPending = true;
|
||||||
|
timer?.cancel();
|
||||||
|
timer = Timer(duration, flush);
|
||||||
|
},
|
||||||
|
onError: controller.addError,
|
||||||
|
onDone: () {
|
||||||
|
timer?.cancel();
|
||||||
|
flush();
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCancel: () async {
|
||||||
|
timer?.cancel();
|
||||||
|
await sub?.cancel();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class AppDatabase extends _$AppDatabase {
|
||||||
|
|
||||||
/// Current schema version; also stamped into interchange exports so an
|
/// Current schema version; also stamped into interchange exports so an
|
||||||
/// importer knows which app generation wrote the file (data-model §7).
|
/// importer knows which app generation wrote the file (data-model §7).
|
||||||
static const int currentSchemaVersion = 13;
|
static const int currentSchemaVersion = 14;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => currentSchemaVersion;
|
int get schemaVersion => currentSchemaVersion;
|
||||||
|
|
@ -196,9 +196,40 @@ class AppDatabase extends _$AppDatabase {
|
||||||
await m.createTable(gardenOutcomes);
|
await m.createTable(gardenOutcomes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v14: scalability for large inventories. A local, regenerable [thumbnail]
|
||||||
|
// BLOB on attachments (so the list never decodes full-resolution photos)
|
||||||
|
// plus indexes on the columns the inventory list filters/joins on. All
|
||||||
|
// additive and guarded, so a half-migrated dev database re-runs cleanly
|
||||||
|
// (see the v7 note above). Existing thumbnails are backfilled lazily in
|
||||||
|
// the background, not here (image decoding in a migration is slow/fragile).
|
||||||
|
if (from < 14) {
|
||||||
|
if (!await _hasColumn('attachments', 'thumbnail')) {
|
||||||
|
await m.addColumn(attachments, attachments.thumbnail);
|
||||||
|
}
|
||||||
|
// Declared as `@TableIndex` on the tables, so a fresh install gets them
|
||||||
|
// via createAll(); existing databases get them here. Guarded against a
|
||||||
|
// half-migrated dev database (see the v7 note above).
|
||||||
|
for (final index in [
|
||||||
|
idxVarietiesDeletedDraft,
|
||||||
|
idxAttachmentsParent,
|
||||||
|
idxLotsVariety,
|
||||||
|
]) {
|
||||||
|
if (!await _hasIndex(index.entityName)) await m.create(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Whether an index named [name] already exists — keeps the additive v14
|
||||||
|
/// index creation idempotent against partially-migrated databases.
|
||||||
|
Future<bool> _hasIndex(String name) async {
|
||||||
|
final rows = await customSelect(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?",
|
||||||
|
variables: [Variable.withString(name)],
|
||||||
|
).get();
|
||||||
|
return rows.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a table named [table] already exists. Keeps the additive v8
|
/// Whether a table named [table] already exists. Keeps the additive v8
|
||||||
/// table creation idempotent against partially-migrated databases.
|
/// table creation idempotent against partially-migrated databases.
|
||||||
Future<bool> _hasTable(String table) async {
|
Future<bool> _hasTable(String table) async {
|
||||||
|
|
|
||||||
|
|
@ -7975,6 +7975,17 @@ class $AttachmentsTable extends Attachments
|
||||||
type: DriftSqlType.blob,
|
type: DriftSqlType.blob,
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _thumbnailMeta = const VerificationMeta(
|
||||||
|
'thumbnail',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<Uint8List> thumbnail = GeneratedColumn<Uint8List>(
|
||||||
|
'thumbnail',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.blob,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
static const VerificationMeta _mimeTypeMeta = const VerificationMeta(
|
static const VerificationMeta _mimeTypeMeta = const VerificationMeta(
|
||||||
'mimeType',
|
'mimeType',
|
||||||
);
|
);
|
||||||
|
|
@ -8011,6 +8022,7 @@ class $AttachmentsTable extends Attachments
|
||||||
kind,
|
kind,
|
||||||
uri,
|
uri,
|
||||||
bytes,
|
bytes,
|
||||||
|
thumbnail,
|
||||||
mimeType,
|
mimeType,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
];
|
];
|
||||||
|
|
@ -8090,6 +8102,12 @@ class $AttachmentsTable extends Attachments
|
||||||
bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta),
|
bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('thumbnail')) {
|
||||||
|
context.handle(
|
||||||
|
_thumbnailMeta,
|
||||||
|
thumbnail.isAcceptableOrUnknown(data['thumbnail']!, _thumbnailMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.containsKey('mime_type')) {
|
if (data.containsKey('mime_type')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_mimeTypeMeta,
|
_mimeTypeMeta,
|
||||||
|
|
@ -8159,6 +8177,10 @@ class $AttachmentsTable extends Attachments
|
||||||
DriftSqlType.blob,
|
DriftSqlType.blob,
|
||||||
data['${effectivePrefix}bytes'],
|
data['${effectivePrefix}bytes'],
|
||||||
),
|
),
|
||||||
|
thumbnail: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.blob,
|
||||||
|
data['${effectivePrefix}thumbnail'],
|
||||||
|
),
|
||||||
mimeType: attachedDatabase.typeMapping.read(
|
mimeType: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}mime_type'],
|
data['${effectivePrefix}mime_type'],
|
||||||
|
|
@ -8193,6 +8215,13 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
final AttachmentKind kind;
|
final AttachmentKind kind;
|
||||||
final String? uri;
|
final String? uri;
|
||||||
final Uint8List? bytes;
|
final Uint8List? bytes;
|
||||||
|
|
||||||
|
/// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so
|
||||||
|
/// the inventory list never has to decode the full-resolution photo for a
|
||||||
|
/// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from
|
||||||
|
/// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a
|
||||||
|
/// restored backup regenerates it lazily via `backfillThumbnails`.
|
||||||
|
final Uint8List? thumbnail;
|
||||||
final String? mimeType;
|
final String? mimeType;
|
||||||
|
|
||||||
/// Display order among sibling attachments (lower first). The lowest-ordered
|
/// Display order among sibling attachments (lower first). The lowest-ordered
|
||||||
|
|
@ -8212,6 +8241,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
required this.kind,
|
required this.kind,
|
||||||
this.uri,
|
this.uri,
|
||||||
this.bytes,
|
this.bytes,
|
||||||
|
this.thumbnail,
|
||||||
this.mimeType,
|
this.mimeType,
|
||||||
required this.sortOrder,
|
required this.sortOrder,
|
||||||
});
|
});
|
||||||
|
|
@ -8241,6 +8271,9 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
if (!nullToAbsent || bytes != null) {
|
if (!nullToAbsent || bytes != null) {
|
||||||
map['bytes'] = Variable<Uint8List>(bytes);
|
map['bytes'] = Variable<Uint8List>(bytes);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || thumbnail != null) {
|
||||||
|
map['thumbnail'] = Variable<Uint8List>(thumbnail);
|
||||||
|
}
|
||||||
if (!nullToAbsent || mimeType != null) {
|
if (!nullToAbsent || mimeType != null) {
|
||||||
map['mime_type'] = Variable<String>(mimeType);
|
map['mime_type'] = Variable<String>(mimeType);
|
||||||
}
|
}
|
||||||
|
|
@ -8263,6 +8296,9 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
bytes: bytes == null && nullToAbsent
|
bytes: bytes == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(bytes),
|
: Value(bytes),
|
||||||
|
thumbnail: thumbnail == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(thumbnail),
|
||||||
mimeType: mimeType == null && nullToAbsent
|
mimeType: mimeType == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(mimeType),
|
: Value(mimeType),
|
||||||
|
|
@ -8291,6 +8327,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
),
|
),
|
||||||
uri: serializer.fromJson<String?>(json['uri']),
|
uri: serializer.fromJson<String?>(json['uri']),
|
||||||
bytes: serializer.fromJson<Uint8List?>(json['bytes']),
|
bytes: serializer.fromJson<Uint8List?>(json['bytes']),
|
||||||
|
thumbnail: serializer.fromJson<Uint8List?>(json['thumbnail']),
|
||||||
mimeType: serializer.fromJson<String?>(json['mimeType']),
|
mimeType: serializer.fromJson<String?>(json['mimeType']),
|
||||||
sortOrder: serializer.fromJson<int>(json['sortOrder']),
|
sortOrder: serializer.fromJson<int>(json['sortOrder']),
|
||||||
);
|
);
|
||||||
|
|
@ -8314,6 +8351,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
),
|
),
|
||||||
'uri': serializer.toJson<String?>(uri),
|
'uri': serializer.toJson<String?>(uri),
|
||||||
'bytes': serializer.toJson<Uint8List?>(bytes),
|
'bytes': serializer.toJson<Uint8List?>(bytes),
|
||||||
|
'thumbnail': serializer.toJson<Uint8List?>(thumbnail),
|
||||||
'mimeType': serializer.toJson<String?>(mimeType),
|
'mimeType': serializer.toJson<String?>(mimeType),
|
||||||
'sortOrder': serializer.toJson<int>(sortOrder),
|
'sortOrder': serializer.toJson<int>(sortOrder),
|
||||||
};
|
};
|
||||||
|
|
@ -8331,6 +8369,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
AttachmentKind? kind,
|
AttachmentKind? kind,
|
||||||
Value<String?> uri = const Value.absent(),
|
Value<String?> uri = const Value.absent(),
|
||||||
Value<Uint8List?> bytes = const Value.absent(),
|
Value<Uint8List?> bytes = const Value.absent(),
|
||||||
|
Value<Uint8List?> thumbnail = const Value.absent(),
|
||||||
Value<String?> mimeType = const Value.absent(),
|
Value<String?> mimeType = const Value.absent(),
|
||||||
int? sortOrder,
|
int? sortOrder,
|
||||||
}) => Attachment(
|
}) => Attachment(
|
||||||
|
|
@ -8345,6 +8384,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
kind: kind ?? this.kind,
|
kind: kind ?? this.kind,
|
||||||
uri: uri.present ? uri.value : this.uri,
|
uri: uri.present ? uri.value : this.uri,
|
||||||
bytes: bytes.present ? bytes.value : this.bytes,
|
bytes: bytes.present ? bytes.value : this.bytes,
|
||||||
|
thumbnail: thumbnail.present ? thumbnail.value : this.thumbnail,
|
||||||
mimeType: mimeType.present ? mimeType.value : this.mimeType,
|
mimeType: mimeType.present ? mimeType.value : this.mimeType,
|
||||||
sortOrder: sortOrder ?? this.sortOrder,
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
);
|
);
|
||||||
|
|
@ -8367,6 +8407,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
kind: data.kind.present ? data.kind.value : this.kind,
|
kind: data.kind.present ? data.kind.value : this.kind,
|
||||||
uri: data.uri.present ? data.uri.value : this.uri,
|
uri: data.uri.present ? data.uri.value : this.uri,
|
||||||
bytes: data.bytes.present ? data.bytes.value : this.bytes,
|
bytes: data.bytes.present ? data.bytes.value : this.bytes,
|
||||||
|
thumbnail: data.thumbnail.present ? data.thumbnail.value : this.thumbnail,
|
||||||
mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType,
|
mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType,
|
||||||
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
|
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
|
||||||
);
|
);
|
||||||
|
|
@ -8386,6 +8427,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
..write('kind: $kind, ')
|
..write('kind: $kind, ')
|
||||||
..write('uri: $uri, ')
|
..write('uri: $uri, ')
|
||||||
..write('bytes: $bytes, ')
|
..write('bytes: $bytes, ')
|
||||||
|
..write('thumbnail: $thumbnail, ')
|
||||||
..write('mimeType: $mimeType, ')
|
..write('mimeType: $mimeType, ')
|
||||||
..write('sortOrder: $sortOrder')
|
..write('sortOrder: $sortOrder')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
|
|
@ -8405,6 +8447,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
kind,
|
kind,
|
||||||
uri,
|
uri,
|
||||||
$driftBlobEquality.hash(bytes),
|
$driftBlobEquality.hash(bytes),
|
||||||
|
$driftBlobEquality.hash(thumbnail),
|
||||||
mimeType,
|
mimeType,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
);
|
);
|
||||||
|
|
@ -8423,6 +8466,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
|
||||||
other.kind == this.kind &&
|
other.kind == this.kind &&
|
||||||
other.uri == this.uri &&
|
other.uri == this.uri &&
|
||||||
$driftBlobEquality.equals(other.bytes, this.bytes) &&
|
$driftBlobEquality.equals(other.bytes, this.bytes) &&
|
||||||
|
$driftBlobEquality.equals(other.thumbnail, this.thumbnail) &&
|
||||||
other.mimeType == this.mimeType &&
|
other.mimeType == this.mimeType &&
|
||||||
other.sortOrder == this.sortOrder);
|
other.sortOrder == this.sortOrder);
|
||||||
}
|
}
|
||||||
|
|
@ -8439,6 +8483,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
final Value<AttachmentKind> kind;
|
final Value<AttachmentKind> kind;
|
||||||
final Value<String?> uri;
|
final Value<String?> uri;
|
||||||
final Value<Uint8List?> bytes;
|
final Value<Uint8List?> bytes;
|
||||||
|
final Value<Uint8List?> thumbnail;
|
||||||
final Value<String?> mimeType;
|
final Value<String?> mimeType;
|
||||||
final Value<int> sortOrder;
|
final Value<int> sortOrder;
|
||||||
final Value<int> rowid;
|
final Value<int> rowid;
|
||||||
|
|
@ -8454,6 +8499,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
this.kind = const Value.absent(),
|
this.kind = const Value.absent(),
|
||||||
this.uri = const Value.absent(),
|
this.uri = const Value.absent(),
|
||||||
this.bytes = const Value.absent(),
|
this.bytes = const Value.absent(),
|
||||||
|
this.thumbnail = const Value.absent(),
|
||||||
this.mimeType = const Value.absent(),
|
this.mimeType = const Value.absent(),
|
||||||
this.sortOrder = const Value.absent(),
|
this.sortOrder = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
|
|
@ -8470,6 +8516,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
required AttachmentKind kind,
|
required AttachmentKind kind,
|
||||||
this.uri = const Value.absent(),
|
this.uri = const Value.absent(),
|
||||||
this.bytes = const Value.absent(),
|
this.bytes = const Value.absent(),
|
||||||
|
this.thumbnail = const Value.absent(),
|
||||||
this.mimeType = const Value.absent(),
|
this.mimeType = const Value.absent(),
|
||||||
this.sortOrder = const Value.absent(),
|
this.sortOrder = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
|
|
@ -8492,6 +8539,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
Expression<String>? kind,
|
Expression<String>? kind,
|
||||||
Expression<String>? uri,
|
Expression<String>? uri,
|
||||||
Expression<Uint8List>? bytes,
|
Expression<Uint8List>? bytes,
|
||||||
|
Expression<Uint8List>? thumbnail,
|
||||||
Expression<String>? mimeType,
|
Expression<String>? mimeType,
|
||||||
Expression<int>? sortOrder,
|
Expression<int>? sortOrder,
|
||||||
Expression<int>? rowid,
|
Expression<int>? rowid,
|
||||||
|
|
@ -8508,6 +8556,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
if (kind != null) 'kind': kind,
|
if (kind != null) 'kind': kind,
|
||||||
if (uri != null) 'uri': uri,
|
if (uri != null) 'uri': uri,
|
||||||
if (bytes != null) 'bytes': bytes,
|
if (bytes != null) 'bytes': bytes,
|
||||||
|
if (thumbnail != null) 'thumbnail': thumbnail,
|
||||||
if (mimeType != null) 'mime_type': mimeType,
|
if (mimeType != null) 'mime_type': mimeType,
|
||||||
if (sortOrder != null) 'sort_order': sortOrder,
|
if (sortOrder != null) 'sort_order': sortOrder,
|
||||||
if (rowid != null) 'rowid': rowid,
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
|
@ -8526,6 +8575,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
Value<AttachmentKind>? kind,
|
Value<AttachmentKind>? kind,
|
||||||
Value<String?>? uri,
|
Value<String?>? uri,
|
||||||
Value<Uint8List?>? bytes,
|
Value<Uint8List?>? bytes,
|
||||||
|
Value<Uint8List?>? thumbnail,
|
||||||
Value<String?>? mimeType,
|
Value<String?>? mimeType,
|
||||||
Value<int>? sortOrder,
|
Value<int>? sortOrder,
|
||||||
Value<int>? rowid,
|
Value<int>? rowid,
|
||||||
|
|
@ -8542,6 +8592,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
kind: kind ?? this.kind,
|
kind: kind ?? this.kind,
|
||||||
uri: uri ?? this.uri,
|
uri: uri ?? this.uri,
|
||||||
bytes: bytes ?? this.bytes,
|
bytes: bytes ?? this.bytes,
|
||||||
|
thumbnail: thumbnail ?? this.thumbnail,
|
||||||
mimeType: mimeType ?? this.mimeType,
|
mimeType: mimeType ?? this.mimeType,
|
||||||
sortOrder: sortOrder ?? this.sortOrder,
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
rowid: rowid ?? this.rowid,
|
rowid: rowid ?? this.rowid,
|
||||||
|
|
@ -8588,6 +8639,9 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
if (bytes.present) {
|
if (bytes.present) {
|
||||||
map['bytes'] = Variable<Uint8List>(bytes.value);
|
map['bytes'] = Variable<Uint8List>(bytes.value);
|
||||||
}
|
}
|
||||||
|
if (thumbnail.present) {
|
||||||
|
map['thumbnail'] = Variable<Uint8List>(thumbnail.value);
|
||||||
|
}
|
||||||
if (mimeType.present) {
|
if (mimeType.present) {
|
||||||
map['mime_type'] = Variable<String>(mimeType.value);
|
map['mime_type'] = Variable<String>(mimeType.value);
|
||||||
}
|
}
|
||||||
|
|
@ -8614,6 +8668,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
|
||||||
..write('kind: $kind, ')
|
..write('kind: $kind, ')
|
||||||
..write('uri: $uri, ')
|
..write('uri: $uri, ')
|
||||||
..write('bytes: $bytes, ')
|
..write('bytes: $bytes, ')
|
||||||
|
..write('thumbnail: $thumbnail, ')
|
||||||
..write('mimeType: $mimeType, ')
|
..write('mimeType: $mimeType, ')
|
||||||
..write('sortOrder: $sortOrder, ')
|
..write('sortOrder: $sortOrder, ')
|
||||||
..write('rowid: $rowid')
|
..write('rowid: $rowid')
|
||||||
|
|
@ -11435,6 +11490,18 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this);
|
late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this);
|
||||||
late final $PlantaresTable plantares = $PlantaresTable(this);
|
late final $PlantaresTable plantares = $PlantaresTable(this);
|
||||||
late final $SalesTable sales = $SalesTable(this);
|
late final $SalesTable sales = $SalesTable(this);
|
||||||
|
late final Index idxVarietiesDeletedDraft = Index(
|
||||||
|
'idx_varieties_deleted_draft',
|
||||||
|
'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)',
|
||||||
|
);
|
||||||
|
late final Index idxLotsVariety = Index(
|
||||||
|
'idx_lots_variety',
|
||||||
|
'CREATE INDEX idx_lots_variety ON lots (variety_id)',
|
||||||
|
);
|
||||||
|
late final Index idxAttachmentsParent = Index(
|
||||||
|
'idx_attachments_parent',
|
||||||
|
'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)',
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
|
@ -11454,6 +11521,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
externalLinks,
|
externalLinks,
|
||||||
plantares,
|
plantares,
|
||||||
sales,
|
sales,
|
||||||
|
idxVarietiesDeletedDraft,
|
||||||
|
idxLotsVariety,
|
||||||
|
idxAttachmentsParent,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15118,6 +15188,7 @@ typedef $$AttachmentsTableCreateCompanionBuilder =
|
||||||
required AttachmentKind kind,
|
required AttachmentKind kind,
|
||||||
Value<String?> uri,
|
Value<String?> uri,
|
||||||
Value<Uint8List?> bytes,
|
Value<Uint8List?> bytes,
|
||||||
|
Value<Uint8List?> thumbnail,
|
||||||
Value<String?> mimeType,
|
Value<String?> mimeType,
|
||||||
Value<int> sortOrder,
|
Value<int> sortOrder,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
|
|
@ -15135,6 +15206,7 @@ typedef $$AttachmentsTableUpdateCompanionBuilder =
|
||||||
Value<AttachmentKind> kind,
|
Value<AttachmentKind> kind,
|
||||||
Value<String?> uri,
|
Value<String?> uri,
|
||||||
Value<Uint8List?> bytes,
|
Value<Uint8List?> bytes,
|
||||||
|
Value<Uint8List?> thumbnail,
|
||||||
Value<String?> mimeType,
|
Value<String?> mimeType,
|
||||||
Value<int> sortOrder,
|
Value<int> sortOrder,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
|
|
@ -15206,6 +15278,11 @@ class $$AttachmentsTableFilterComposer
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<Uint8List> get thumbnail => $composableBuilder(
|
||||||
|
column: $table.thumbnail,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<String> get mimeType => $composableBuilder(
|
ColumnFilters<String> get mimeType => $composableBuilder(
|
||||||
column: $table.mimeType,
|
column: $table.mimeType,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
|
|
@ -15281,6 +15358,11 @@ class $$AttachmentsTableOrderingComposer
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<Uint8List> get thumbnail => $composableBuilder(
|
||||||
|
column: $table.thumbnail,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<String> get mimeType => $composableBuilder(
|
ColumnOrderings<String> get mimeType => $composableBuilder(
|
||||||
column: $table.mimeType,
|
column: $table.mimeType,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
|
@ -15341,6 +15423,9 @@ class $$AttachmentsTableAnnotationComposer
|
||||||
GeneratedColumn<Uint8List> get bytes =>
|
GeneratedColumn<Uint8List> get bytes =>
|
||||||
$composableBuilder(column: $table.bytes, builder: (column) => column);
|
$composableBuilder(column: $table.bytes, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<Uint8List> get thumbnail =>
|
||||||
|
$composableBuilder(column: $table.thumbnail, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<String> get mimeType =>
|
GeneratedColumn<String> get mimeType =>
|
||||||
$composableBuilder(column: $table.mimeType, builder: (column) => column);
|
$composableBuilder(column: $table.mimeType, builder: (column) => column);
|
||||||
|
|
||||||
|
|
@ -15390,6 +15475,7 @@ class $$AttachmentsTableTableManager
|
||||||
Value<AttachmentKind> kind = const Value.absent(),
|
Value<AttachmentKind> kind = const Value.absent(),
|
||||||
Value<String?> uri = const Value.absent(),
|
Value<String?> uri = const Value.absent(),
|
||||||
Value<Uint8List?> bytes = const Value.absent(),
|
Value<Uint8List?> bytes = const Value.absent(),
|
||||||
|
Value<Uint8List?> thumbnail = const Value.absent(),
|
||||||
Value<String?> mimeType = const Value.absent(),
|
Value<String?> mimeType = const Value.absent(),
|
||||||
Value<int> sortOrder = const Value.absent(),
|
Value<int> sortOrder = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
|
|
@ -15405,6 +15491,7 @@ class $$AttachmentsTableTableManager
|
||||||
kind: kind,
|
kind: kind,
|
||||||
uri: uri,
|
uri: uri,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
|
thumbnail: thumbnail,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
sortOrder: sortOrder,
|
sortOrder: sortOrder,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
|
|
@ -15422,6 +15509,7 @@ class $$AttachmentsTableTableManager
|
||||||
required AttachmentKind kind,
|
required AttachmentKind kind,
|
||||||
Value<String?> uri = const Value.absent(),
|
Value<String?> uri = const Value.absent(),
|
||||||
Value<Uint8List?> bytes = const Value.absent(),
|
Value<Uint8List?> bytes = const Value.absent(),
|
||||||
|
Value<Uint8List?> thumbnail = const Value.absent(),
|
||||||
Value<String?> mimeType = const Value.absent(),
|
Value<String?> mimeType = const Value.absent(),
|
||||||
Value<int> sortOrder = const Value.absent(),
|
Value<int> sortOrder = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
|
|
@ -15437,6 +15525,7 @@ class $$AttachmentsTableTableManager
|
||||||
kind: kind,
|
kind: kind,
|
||||||
uri: uri,
|
uri: uri,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
|
thumbnail: thumbnail,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
sortOrder: sortOrder,
|
sortOrder: sortOrder,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import 'sync_columns.dart';
|
||||||
|
|
||||||
/// The identity/accession — one row per distinct thing in the inventory.
|
/// The identity/accession — one row per distinct thing in the inventory.
|
||||||
/// Only [label] is mandatory (progressive disclosure).
|
/// Only [label] is mandatory (progressive disclosure).
|
||||||
|
///
|
||||||
|
/// The index covers the inventory list's hot filter — non-deleted, non-draft
|
||||||
|
/// rows — so it stays fast with a large catalogue.
|
||||||
|
@TableIndex(name: 'idx_varieties_deleted_draft', columns: {#isDeleted, #isDraft})
|
||||||
class Varieties extends Table with SyncColumns {
|
class Varieties extends Table with SyncColumns {
|
||||||
TextColumn get label => text()();
|
TextColumn get label => text()();
|
||||||
TextColumn get speciesId => text().nullable()(); // → Species
|
TextColumn get speciesId => text().nullable()(); // → Species
|
||||||
|
|
@ -77,6 +81,10 @@ class SpeciesCommonNames extends Table with SyncColumns {
|
||||||
|
|
||||||
/// A homogeneous batch held for a Variety — its own year and its own unit.
|
/// A homogeneous batch held for a Variety — its own year and its own unit.
|
||||||
/// Quantity (commons_core value type) is flattened into columns here.
|
/// Quantity (commons_core value type) is flattened into columns here.
|
||||||
|
///
|
||||||
|
/// Indexed by [varietyId] — the inventory list joins lots per variety (types,
|
||||||
|
/// shared status, viability), so this avoids a full scan per reload.
|
||||||
|
@TableIndex(name: 'idx_lots_variety', columns: {#varietyId})
|
||||||
class Lots extends Table with SyncColumns {
|
class Lots extends Table with SyncColumns {
|
||||||
TextColumn get varietyId => text()();
|
TextColumn get varietyId => text()();
|
||||||
TextColumn get type =>
|
TextColumn get type =>
|
||||||
|
|
@ -181,12 +189,26 @@ class Parties extends Table with SyncColumns {
|
||||||
/// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here
|
/// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here
|
||||||
/// keeps the "no plaintext at rest" rule for photos in Block 1; an external
|
/// keeps the "no plaintext at rest" rule for photos in Block 1; an external
|
||||||
/// encrypted file store is a later optimization.
|
/// encrypted file store is a later optimization.
|
||||||
|
///
|
||||||
|
/// Indexed by (parentType, parentId, kind) — the list's "first photo per
|
||||||
|
/// variety" lookup filters on exactly these.
|
||||||
|
@TableIndex(
|
||||||
|
name: 'idx_attachments_parent',
|
||||||
|
columns: {#parentType, #parentId, #kind},
|
||||||
|
)
|
||||||
class Attachments extends Table with SyncColumns {
|
class Attachments extends Table with SyncColumns {
|
||||||
TextColumn get parentType => textEnum<ParentType>()();
|
TextColumn get parentType => textEnum<ParentType>()();
|
||||||
TextColumn get parentId => text()();
|
TextColumn get parentId => text()();
|
||||||
TextColumn get kind => textEnum<AttachmentKind>()();
|
TextColumn get kind => textEnum<AttachmentKind>()();
|
||||||
TextColumn get uri => text().nullable()();
|
TextColumn get uri => text().nullable()();
|
||||||
BlobColumn get bytes => blob().nullable()();
|
BlobColumn get bytes => blob().nullable()();
|
||||||
|
|
||||||
|
/// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so
|
||||||
|
/// the inventory list never has to decode the full-resolution photo for a
|
||||||
|
/// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from
|
||||||
|
/// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a
|
||||||
|
/// restored backup regenerates it lazily via `backfillThumbnails`.
|
||||||
|
BlobColumn get thumbnail => blob().nullable()();
|
||||||
TextColumn get mimeType => text().nullable()();
|
TextColumn get mimeType => text().nullable()();
|
||||||
|
|
||||||
/// Display order among sibling attachments (lower first). The lowest-ordered
|
/// Display order among sibling attachments (lower first). The lowest-ordered
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import '../services/file_service.dart';
|
||||||
import '../services/inbox_service.dart';
|
import '../services/inbox_service.dart';
|
||||||
import '../services/locale_store.dart';
|
import '../services/locale_store.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
|
import '../services/offer_thumbnail.dart';
|
||||||
import '../services/ocr/label_text_extractor.dart';
|
import '../services/ocr/label_text_extractor.dart';
|
||||||
import '../services/ocr/ocr_language.dart';
|
import '../services/ocr/ocr_language.dart';
|
||||||
import '../services/ocr/tesseract_label_extractor.dart';
|
import '../services/ocr/tesseract_label_extractor.dart';
|
||||||
|
|
@ -152,7 +153,11 @@ Future<void> configureDependencies() async {
|
||||||
database,
|
database,
|
||||||
idGen: IdGen(),
|
idGen: IdGen(),
|
||||||
nodeId: nodeId,
|
nodeId: nodeId,
|
||||||
|
thumbnailBuilder: inventoryThumbnailBytes,
|
||||||
);
|
);
|
||||||
|
// Backfill list thumbnails for photos that predate the thumbnail column (or
|
||||||
|
// arrived via a restored backup). Fire-and-forget so startup isn't blocked.
|
||||||
|
unawaited(varietyRepository.backfillThumbnails());
|
||||||
|
|
||||||
const fileService = FilePickerFileService();
|
const fileService = FilePickerFileService();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'bootstrap.dart';
|
import 'bootstrap.dart';
|
||||||
|
|
@ -6,6 +7,16 @@ import 'ui/restart_widget.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Draw behind the system bars (edge-to-edge) with transparent bars, so the app
|
||||||
|
// fills the screen on Android 15+ (where edge-to-edge is enforced) and stays
|
||||||
|
// consistent elsewhere. Scaffolds use SafeArea to keep content off the insets.
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
|
const SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Color(0x00000000),
|
||||||
|
systemNavigationBarColor: Color(0x00000000),
|
||||||
|
),
|
||||||
|
);
|
||||||
// Start from the device language, then let an explicit in-app choice win —
|
// Start from the device language, then let an explicit in-app choice win —
|
||||||
// it's the only reliable way to reach languages the OS picker doesn't offer
|
// it's the only reliable way to reach languages the OS picker doesn't offer
|
||||||
// (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI.
|
// (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI.
|
||||||
|
|
|
||||||
35
apps/app_seeds/lib/services/camera_availability.dart
Normal file
35
apps/app_seeds/lib/services/camera_availability.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Whether this device has any camera. Resolved once at startup (via the native
|
||||||
|
/// channel, `PackageManager.FEATURE_CAMERA_ANY`) and cached so `build()` methods
|
||||||
|
/// can read it synchronously — the QR scan button ([qrScanSupported]) and the
|
||||||
|
/// photo-source sheet hide their camera options when it is false.
|
||||||
|
///
|
||||||
|
/// Defaults to `true` so a normal phone never loses camera UI over a transient
|
||||||
|
/// channel error; only genuinely camera-less devices (Chromebooks, Android
|
||||||
|
/// Automotive, some TVs — kept installable by the `uses-feature required=false`
|
||||||
|
/// in AndroidManifest) flip it to false. Non-Android platforms keep the default
|
||||||
|
/// (iOS devices have a camera; desktop/web gate camera UI elsewhere).
|
||||||
|
bool _deviceHasCamera = true;
|
||||||
|
|
||||||
|
bool get deviceHasCamera => _deviceHasCamera;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
set deviceHasCamera(bool value) => _deviceHasCamera = value;
|
||||||
|
|
||||||
|
const _channel = MethodChannel('org.comunes.tane/coarse_location');
|
||||||
|
|
||||||
|
/// Queries the native camera-presence check and caches it. Called once during
|
||||||
|
/// bootstrap, before the first real frame, so synchronous readers see the right
|
||||||
|
/// value. Android-only; elsewhere the default stands. Never throws — a channel
|
||||||
|
/// error leaves the safe default in place.
|
||||||
|
Future<void> initCameraAvailability() async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
try {
|
||||||
|
final has = await _channel.invokeMethod<bool>('hasCamera');
|
||||||
|
if (has != null) _deviceHasCamera = has;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep the default; a normal phone shouldn't lose camera UI over this.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,26 @@ String? offerThumbnailDataUri(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a small JPEG thumbnail (longest edge [edge]px) for the inventory-list
|
||||||
|
/// avatar, so the list never has to decode the full-resolution photo. Returns
|
||||||
|
/// raw JPEG bytes, or null on undecodable input (the caller falls back to the
|
||||||
|
/// full photo). Kept as local bytes — regenerable, so it is excluded from sync
|
||||||
|
/// and backups.
|
||||||
|
Uint8List? inventoryThumbnailBytes(
|
||||||
|
Uint8List bytes, {
|
||||||
|
int edge = 96,
|
||||||
|
int quality = 72,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded == null) return null;
|
||||||
|
final resized = _fitWithin(decoded, edge);
|
||||||
|
return Uint8List.fromList(img.encodeJpg(resized, quality: quality));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is
|
/// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is
|
||||||
/// not a base64 data URI. Used by the UI to render an inline thumbnail with
|
/// not a base64 data URI. Used by the UI to render an inline thumbnail with
|
||||||
/// `Image.memory` instead of a network fetch.
|
/// `Image.memory` instead of a network fetch.
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,16 @@ class OffersState extends Equatable {
|
||||||
this.blockedAuthors = const {},
|
this.blockedAuthors = const {},
|
||||||
this.hiddenOfferKeys = const {},
|
this.hiddenOfferKeys = const {},
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
|
this.loadingMore = false,
|
||||||
|
this.nextPageCursor,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = false,
|
this.hasSearched = false,
|
||||||
this.error,
|
this.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Offers discovered so far for [areaGeohash], newest appended as they arrive.
|
/// Offers discovered so far for [areaGeohash], newest first. Bounded in size
|
||||||
|
/// (see [OffersCubit.maxOffersKept]) so a busy area can't grow the list — and
|
||||||
|
/// the inline photo thumbnails it carries — without limit.
|
||||||
final List<Offer> offers;
|
final List<Offer> offers;
|
||||||
|
|
||||||
/// The coarse area currently being browsed.
|
/// The coarse area currently being browsed.
|
||||||
|
|
@ -57,8 +61,21 @@ class OffersState extends Equatable {
|
||||||
final Set<String> hiddenOfferKeys;
|
final Set<String> hiddenOfferKeys;
|
||||||
|
|
||||||
final bool searching;
|
final bool searching;
|
||||||
|
|
||||||
|
/// True while a "load more" page is in flight, so the UI shows a footer
|
||||||
|
/// spinner and doesn't fire overlapping page requests.
|
||||||
|
final bool loadingMore;
|
||||||
|
|
||||||
|
/// Cursor (Unix seconds) for the next older page, or null when the newest
|
||||||
|
/// page was short (nothing older) or the in-memory cap was reached — in both
|
||||||
|
/// cases there is no more to load.
|
||||||
|
final int? nextPageCursor;
|
||||||
|
|
||||||
final bool publishing;
|
final bool publishing;
|
||||||
|
|
||||||
|
/// Whether more offers can be paged in (drives the infinite-scroll trigger).
|
||||||
|
bool get canLoadMore => nextPageCursor != null;
|
||||||
|
|
||||||
/// True once a discovery has been started, so the UI can tell "no search yet"
|
/// True once a discovery has been started, so the UI can tell "no search yet"
|
||||||
/// from "searched, found nothing".
|
/// from "searched, found nothing".
|
||||||
final bool hasSearched;
|
final bool hasSearched;
|
||||||
|
|
@ -115,6 +132,8 @@ class OffersState extends Equatable {
|
||||||
Set<String>? blockedAuthors,
|
Set<String>? blockedAuthors,
|
||||||
Set<String>? hiddenOfferKeys,
|
Set<String>? hiddenOfferKeys,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
|
bool? loadingMore,
|
||||||
|
int? Function()? nextPageCursor,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
String? Function()? error,
|
String? Function()? error,
|
||||||
|
|
@ -129,6 +148,9 @@ class OffersState extends Equatable {
|
||||||
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
||||||
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
|
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
|
loadingMore: loadingMore ?? this.loadingMore,
|
||||||
|
nextPageCursor:
|
||||||
|
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
error: error != null ? error() : this.error,
|
error: error != null ? error() : this.error,
|
||||||
|
|
@ -146,6 +168,8 @@ class OffersState extends Equatable {
|
||||||
blockedAuthors,
|
blockedAuthors,
|
||||||
hiddenOfferKeys,
|
hiddenOfferKeys,
|
||||||
searching,
|
searching,
|
||||||
|
loadingMore,
|
||||||
|
nextPageCursor,
|
||||||
publishing,
|
publishing,
|
||||||
hasSearched,
|
hasSearched,
|
||||||
error,
|
error,
|
||||||
|
|
@ -183,10 +207,23 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
StreamSubscription<Offer>? _sub;
|
StreamSubscription<Offer>? _sub;
|
||||||
Timer? _searchTimeout;
|
Timer? _searchTimeout;
|
||||||
|
|
||||||
|
/// Upper bound on offers held in memory. Each offer can carry an inline
|
||||||
|
/// (~40 KB) photo thumbnail, so an unbounded list in a busy area is a real
|
||||||
|
/// out-of-memory risk on mobile. Paging stops once the list reaches this, and
|
||||||
|
/// live offers beyond it evict the oldest — the newest stay visible.
|
||||||
|
static const int maxOffersKept = 400;
|
||||||
|
|
||||||
/// Whether a live transport is available (relay configured and reachable).
|
/// Whether a live transport is available (relay configured and reachable).
|
||||||
bool get isOnline => _transport != null;
|
bool get isOnline => _transport != null;
|
||||||
|
|
||||||
/// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
|
/// Current time in Unix seconds — the granularity Nostr filters use for
|
||||||
|
/// `since`/`until`.
|
||||||
|
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
/// Starts (or restarts) discovery for [geohashPrefix]. Fetches the newest page
|
||||||
|
/// up front (bounded), then keeps a live subscription for offers published
|
||||||
|
/// from now on — older results arrive via [loadNextPage], not by draining the
|
||||||
|
/// whole area into memory.
|
||||||
Future<void> discover(String geohashPrefix) async {
|
Future<void> discover(String geohashPrefix) async {
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
if (transport == null) {
|
if (transport == null) {
|
||||||
|
|
@ -207,15 +244,38 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
searching: true,
|
searching: true,
|
||||||
hasSearched: true,
|
hasSearched: true,
|
||||||
));
|
));
|
||||||
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
|
|
||||||
(offer) =>
|
final query = DiscoveryQuery(
|
||||||
emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
|
geohashPrefix: geohashPrefix,
|
||||||
onError: (Object e) =>
|
types: state.typeFilter,
|
||||||
emit(state.copyWith(searching: false, error: () => '$e')),
|
|
||||||
);
|
);
|
||||||
// The discover stream stays open for live offers and never signals "done",
|
// Live subscription first, bounded to offers published from now on, so a new
|
||||||
// so stop the spinner after a beat: no results → show the empty state, not
|
// listing shows up immediately without re-dumping the whole area.
|
||||||
// an endless "searching".
|
final since = _now();
|
||||||
|
_sub = transport.discover(query, since: since).listen(
|
||||||
|
(offer) => emit(state.copyWith(
|
||||||
|
offers: _capped(_prepend(state.offers, offer)),
|
||||||
|
searching: false,
|
||||||
|
)),
|
||||||
|
onError: (Object e) =>
|
||||||
|
emit(state.copyWith(searching: false, error: () => '$e')),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final page = await transport.discoverPage(query);
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
offers: _capped(_mergePage(state.offers, page.offers)),
|
||||||
|
nextPageCursor: () => page.nextCursor,
|
||||||
|
searching: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!isClosed) emit(state.copyWith(searching: false, error: () => '$e'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety net: if the first page hangs and no live offer arrives, still stop
|
||||||
|
// the spinner so the screen shows the empty state, not an endless search.
|
||||||
_searchTimeout = Timer(const Duration(seconds: 6), () {
|
_searchTimeout = Timer(const Duration(seconds: 6), () {
|
||||||
if (!isClosed && state.searching) {
|
if (!isClosed && state.searching) {
|
||||||
emit(state.copyWith(searching: false));
|
emit(state.copyWith(searching: false));
|
||||||
|
|
@ -223,18 +283,64 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends [incoming] to [current], replacing any existing offer with the same
|
/// Loads the next (older) page of offers for the current area. Called by the
|
||||||
/// author + id. Relays legitimately resend addressable events (a stored copy
|
/// list as it nears the end. No-op when offline, already loading, or there is
|
||||||
/// plus a live echo after publishing), so a plain append would double the
|
/// nothing older to fetch.
|
||||||
/// listing; keeping one entry per (author, id) is the NIP-99 semantics.
|
Future<void> loadNextPage() async {
|
||||||
static List<Offer> _merge(List<Offer> current, Offer incoming) => [
|
final transport = _transport;
|
||||||
for (final o in current)
|
final cursor = state.nextPageCursor;
|
||||||
if (!(o.id == incoming.id &&
|
if (transport == null || cursor == null || state.loadingMore) return;
|
||||||
o.authorPubkeyHex == incoming.authorPubkeyHex))
|
emit(state.copyWith(loadingMore: true));
|
||||||
o,
|
try {
|
||||||
|
final page = await transport.discoverPage(DiscoveryQuery(
|
||||||
|
geohashPrefix: state.areaGeohash,
|
||||||
|
types: state.typeFilter,
|
||||||
|
until: cursor,
|
||||||
|
));
|
||||||
|
final merged = _mergePage(state.offers, page.offers);
|
||||||
|
// Stop paging once the cap is reached — the list is already as large as we
|
||||||
|
// keep — otherwise carry the transport's cursor onward.
|
||||||
|
final reachedCap = merged.length >= maxOffersKept;
|
||||||
|
emit(state.copyWith(
|
||||||
|
offers: _capped(merged),
|
||||||
|
nextPageCursor: () => reachedCap ? null : page.nextCursor,
|
||||||
|
loadingMore: false,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(loadingMore: false, error: () => '$e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The offer's identity for de-duplication: NIP-99 addressable events are keyed
|
||||||
|
/// by (author, `d`-tag id), so the same listing from two relays — or a stored
|
||||||
|
/// copy plus a live echo — collapses to one entry.
|
||||||
|
static String _key(Offer o) => '${o.authorPubkeyHex}:${o.id}';
|
||||||
|
|
||||||
|
/// Prepends a freshly-arrived (newer) [incoming] offer, dropping any existing
|
||||||
|
/// entry with the same identity so a live echo never doubles the listing.
|
||||||
|
static List<Offer> _prepend(List<Offer> current, Offer incoming) => [
|
||||||
incoming,
|
incoming,
|
||||||
|
for (final o in current)
|
||||||
|
if (_key(o) != _key(incoming)) o,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Appends an older [page] after [current] (older offers sort below newer),
|
||||||
|
/// skipping any already present. Keeps the newest-first ordering.
|
||||||
|
static List<Offer> _mergePage(List<Offer> current, List<Offer> page) {
|
||||||
|
final seen = {for (final o in current) _key(o)};
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
for (final o in page)
|
||||||
|
if (seen.add(_key(o))) o,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caps the list to [maxOffersKept], keeping the newest (front) and dropping
|
||||||
|
/// the oldest (tail) — bounds memory in a busy area.
|
||||||
|
static List<Offer> _capped(List<Offer> offers) => offers.length <= maxOffersKept
|
||||||
|
? offers
|
||||||
|
: offers.sublist(0, maxOffersKept);
|
||||||
|
|
||||||
/// Narrows the visible offers to those whose summary matches [query]. Purely
|
/// Narrows the visible offers to those whose summary matches [query]. Purely
|
||||||
/// local over the already-discovered list; does not re-hit the transport.
|
/// local over the already-discovered list; does not re-hit the transport.
|
||||||
void search(String query) => emit(state.copyWith(query: query));
|
void search(String query) => emit(state.copyWith(query: query));
|
||||||
|
|
|
||||||
|
|
@ -235,8 +235,14 @@ class _CalendarRow extends StatelessWidget {
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: swatch.fill,
|
backgroundColor: swatch.fill,
|
||||||
foregroundImage:
|
// Decode to the 40px avatar size (× dpr), not the full photo.
|
||||||
entry.photo == null ? null : MemoryImage(entry.photo!),
|
foregroundImage: entry.photo == null
|
||||||
|
? null
|
||||||
|
: ResizeImage(
|
||||||
|
MemoryImage(entry.photo!),
|
||||||
|
width: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
|
height: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
|
),
|
||||||
child: entry.photo == null
|
child: entry.photo == null
|
||||||
? Text(
|
? Text(
|
||||||
entry.label.isEmpty
|
entry.label.isEmpty
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@ class _DraftTile extends StatelessWidget {
|
||||||
photo,
|
photo,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
cacheWidth:
|
||||||
|
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
|
cacheHeight:
|
||||||
|
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -192,6 +196,8 @@ class _NameDraftDialogState extends State<NameDraftDialog> {
|
||||||
child: Image.memory(
|
child: Image.memory(
|
||||||
widget.photo!,
|
widget.photo!,
|
||||||
height: 140,
|
height: 140,
|
||||||
|
cacheHeight:
|
||||||
|
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -582,27 +582,47 @@ class _InventoryBody extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items arrive ordered by category then label; insert a header whenever the
|
// Items arrive ordered by category then label; insert a header whenever the
|
||||||
// category changes.
|
// category changes. Precompute a flat row model (header or item) so the
|
||||||
final rows = <Widget>[];
|
// ListView can build tiles lazily — with a large inventory, building every
|
||||||
|
// tile upfront (the old `ListView(children:)`) stalled the first paint.
|
||||||
|
final rows = <_InventoryRow>[];
|
||||||
String? currentCategory;
|
String? currentCategory;
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final category = item.category ?? t.inventory.uncategorized;
|
final category = item.category ?? t.inventory.uncategorized;
|
||||||
if (category != currentCategory) {
|
if (category != currentCategory) {
|
||||||
currentCategory = category;
|
currentCategory = category;
|
||||||
rows.add(_CategoryHeader(title: category));
|
rows.add(_InventoryRow.header(category));
|
||||||
}
|
}
|
||||||
rows.add(
|
rows.add(_InventoryRow.variety(item));
|
||||||
_VarietyTile(
|
|
||||||
item: item,
|
|
||||||
selectionMode: selectionMode,
|
|
||||||
selected: selectedIds.contains(item.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ListView(children: rows);
|
return ListView.builder(
|
||||||
|
itemCount: rows.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final row = rows[i];
|
||||||
|
final header = row.header;
|
||||||
|
if (header != null) {
|
||||||
|
return _CategoryHeader(title: header);
|
||||||
|
}
|
||||||
|
return _VarietyTile(
|
||||||
|
item: row.item!,
|
||||||
|
selectionMode: selectionMode,
|
||||||
|
selected: selectedIds.contains(row.item!.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single row in the flattened inventory list: either a category [header] or
|
||||||
|
/// a variety [item] (exactly one is non-null).
|
||||||
|
class _InventoryRow {
|
||||||
|
const _InventoryRow.header(this.header) : item = null;
|
||||||
|
const _InventoryRow.variety(this.item) : header = null;
|
||||||
|
|
||||||
|
final String? header;
|
||||||
|
final VarietyListItem? item;
|
||||||
|
}
|
||||||
|
|
||||||
class _CategoryHeader extends StatelessWidget {
|
class _CategoryHeader extends StatelessWidget {
|
||||||
const _CategoryHeader({required this.title});
|
const _CategoryHeader({required this.title});
|
||||||
|
|
||||||
|
|
@ -767,10 +787,21 @@ class _Avatar extends StatelessWidget {
|
||||||
// Decorative: the tile title already announces the variety name, so keep
|
// Decorative: the tile title already announces the variety name, so keep
|
||||||
// the thumbnail / initial out of the semantics tree.
|
// the thumbnail / initial out of the semantics tree.
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
|
// Decode straight to the 48px avatar size (× device pixel ratio) instead
|
||||||
|
// of holding the full-resolution photo in the image cache — decisive for
|
||||||
|
// memory with a large inventory of photographed varieties.
|
||||||
|
final cachePx = (48 * MediaQuery.devicePixelRatioOf(context)).round();
|
||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
|
child: Image.memory(
|
||||||
|
photo,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
cacheWidth: cachePx,
|
||||||
|
cacheHeight: cachePx,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -414,23 +414,44 @@ class MarketBody extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: NotificationListener<ScrollNotification>(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
// Page in older offers as the list nears its end, so a
|
||||||
padding: const EdgeInsets.all(16),
|
// large area streams in on demand rather than all at
|
||||||
itemCount: visible.length,
|
// once. The cubit guards against overlapping loads.
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
onNotification: (n) {
|
||||||
itemBuilder: (context, i) {
|
if (state.canLoadMore &&
|
||||||
final o = visible[i];
|
!state.loadingMore &&
|
||||||
final mine = o.authorPubkeyHex == selfPubkey;
|
n.metrics.axis == Axis.vertical &&
|
||||||
return _OfferCard(
|
n.metrics.extentAfter < 500) {
|
||||||
offer: o,
|
cubit.loadNextPage();
|
||||||
mine: mine,
|
}
|
||||||
onTap: () async {
|
return false;
|
||||||
await context.push('/market/offer', extra: o);
|
|
||||||
await onOfferClosed?.call();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: ListView.separated(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
// A trailing spinner row while the next page loads.
|
||||||
|
itemCount: visible.length + (state.loadingMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
if (i >= visible.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final o = visible[i];
|
||||||
|
final mine = o.authorPubkeyHex == selfPubkey;
|
||||||
|
return _OfferCard(
|
||||||
|
offer: o,
|
||||||
|
mine: mine,
|
||||||
|
onTap: () async {
|
||||||
|
await context.push('/market/offer', extra: o);
|
||||||
|
await onOfferClosed?.call();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class OfferThumbnail extends StatelessWidget {
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: _offerImage(
|
child: _offerImage(
|
||||||
|
context,
|
||||||
url,
|
url,
|
||||||
semanticLabel: semanticLabel,
|
semanticLabel: semanticLabel,
|
||||||
width: size,
|
width: size,
|
||||||
|
|
@ -63,7 +64,7 @@ class OfferHeroImage extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 4 / 3,
|
aspectRatio: 4 / 3,
|
||||||
child: _offerImage(url, semanticLabel: semanticLabel),
|
child: _offerImage(context, url, semanticLabel: semanticLabel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +74,7 @@ class OfferHeroImage extends StatelessWidget {
|
||||||
/// remote URL (via network), always cropping to fill and falling back to a
|
/// remote URL (via network), always cropping to fill and falling back to a
|
||||||
/// neutral placeholder — a broken image must never block the card.
|
/// neutral placeholder — a broken image must never block the card.
|
||||||
Widget _offerImage(
|
Widget _offerImage(
|
||||||
|
BuildContext context,
|
||||||
String url, {
|
String url, {
|
||||||
required String semanticLabel,
|
required String semanticLabel,
|
||||||
double? width,
|
double? width,
|
||||||
|
|
@ -80,10 +82,16 @@ Widget _offerImage(
|
||||||
}) {
|
}) {
|
||||||
final inline = decodeDataUri(url);
|
final inline = decodeDataUri(url);
|
||||||
if (inline != null) {
|
if (inline != null) {
|
||||||
|
// For a sized thumbnail, decode down to its on-screen pixels instead of the
|
||||||
|
// photo's full resolution (Play's "bitmap downscaling"). The hero image
|
||||||
|
// passes no width, so it decodes at native size as intended.
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
return Image.memory(
|
return Image.memory(
|
||||||
inline,
|
inline,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
cacheWidth: width == null ? null : (width * dpr).round(),
|
||||||
|
cacheHeight: height == null ? null : (height * dpr).round(),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
semanticLabel: semanticLabel,
|
semanticLabel: semanticLabel,
|
||||||
errorBuilder: (context, _, _) =>
|
errorBuilder: (context, _, _) =>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,19 @@ class PeerAvatar extends StatelessWidget {
|
||||||
if (pic.startsWith('data:')) {
|
if (pic.startsWith('data:')) {
|
||||||
final bytes = decodeDataUri(pic);
|
final bytes = decodeDataUri(pic);
|
||||||
if (bytes != null) {
|
if (bytes != null) {
|
||||||
return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes));
|
// Decode the photo down to the avatar's on-screen size (in physical
|
||||||
|
// pixels) instead of full resolution — the "bitmap downscaling" Play
|
||||||
|
// recommends. A tiny disc never needs a multi-megapixel decode.
|
||||||
|
final side = (radius * 2 * MediaQuery.devicePixelRatioOf(context))
|
||||||
|
.round();
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundImage: ResizeImage(
|
||||||
|
MemoryImage(bytes),
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (pic.startsWith(avatarGlyphPrefix)) {
|
} else if (pic.startsWith(avatarGlyphPrefix)) {
|
||||||
final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length));
|
final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/camera_availability.dart';
|
||||||
|
|
||||||
/// Asks the user to take a photo or pick one from the gallery, then returns its
|
/// Asks the user to take a photo or pick one from the gallery, then returns its
|
||||||
/// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop,
|
/// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop,
|
||||||
|
|
@ -16,12 +17,15 @@ Future<Uint8List?> pickPhoto(BuildContext context) async {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
// Only offer the camera when the device actually has one; camera-less
|
||||||
key: const Key('photo.source.camera'),
|
// devices (Chromebooks, Automotive, some TVs) fall back to gallery.
|
||||||
leading: const Icon(Icons.photo_camera_outlined),
|
if (deviceHasCamera)
|
||||||
title: Text(t.photo.camera),
|
ListTile(
|
||||||
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
key: const Key('photo.source.camera'),
|
||||||
),
|
leading: const Icon(Icons.photo_camera_outlined),
|
||||||
|
title: Text(t.photo.camera),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
key: const Key('photo.source.gallery'),
|
key: const Key('photo.source.gallery'),
|
||||||
leading: const Icon(Icons.photo_library_outlined),
|
leading: const Icon(Icons.photo_library_outlined),
|
||||||
|
|
@ -57,12 +61,15 @@ Future<List<Uint8List>> pickPhotos(BuildContext context) async {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
// Only offer the camera when the device actually has one; camera-less
|
||||||
key: const Key('photos.source.camera'),
|
// devices (Chromebooks, Automotive, some TVs) fall back to gallery.
|
||||||
leading: const Icon(Icons.photo_camera_outlined),
|
if (deviceHasCamera)
|
||||||
title: Text(t.photo.camera),
|
ListTile(
|
||||||
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
key: const Key('photos.source.camera'),
|
||||||
),
|
leading: const Icon(Icons.photo_camera_outlined),
|
||||||
|
title: Text(t.photo.camera),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
key: const Key('photos.source.gallery'),
|
key: const Key('photos.source.gallery'),
|
||||||
leading: const Icon(Icons.photo_library_outlined),
|
leading: const Icon(Icons.photo_library_outlined),
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@ import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart';
|
||||||
import '../data/species_repository.dart';
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/camera_availability.dart';
|
||||||
import '../services/seed_label_scan.dart';
|
import '../services/seed_label_scan.dart';
|
||||||
|
|
||||||
/// Whether this platform can open the camera scanner. Mobile-only for now
|
/// Whether this platform can open the camera scanner. Mobile-only for now
|
||||||
/// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan
|
/// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan
|
||||||
/// button simply isn't offered, same pattern as the OCR capture.
|
/// button simply isn't offered, same pattern as the OCR capture. On Android it
|
||||||
bool get qrScanSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
/// also requires an actual camera, so camera-less devices (Chromebooks,
|
||||||
|
/// Automotive, some TVs) don't get a scan button that can't open — see
|
||||||
|
/// [deviceHasCamera].
|
||||||
|
bool get qrScanSupported =>
|
||||||
|
!kIsWeb && ((Platform.isAndroid && deviceHasCamera) || Platform.isIOS);
|
||||||
|
|
||||||
/// Opens the full-screen camera scanner and returns the first decoded QR
|
/// Opens the full-screen camera scanner and returns the first decoded QR
|
||||||
/// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on
|
/// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,8 @@ class _MoreSection extends StatelessWidget {
|
||||||
child: Image.memory(
|
child: Image.memory(
|
||||||
state.photoBytes!,
|
state.photoBytes!,
|
||||||
height: 120,
|
height: 120,
|
||||||
|
cacheHeight:
|
||||||
|
(120 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2244,10 +2244,16 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
|
||||||
itemBuilder: (_, i) => GestureDetector(
|
itemBuilder: (_, i) => GestureDetector(
|
||||||
key: Key('photo.thumb.$i'),
|
key: Key('photo.thumb.$i'),
|
||||||
onTap: () => _openPhotoViewer(context, cubit, i),
|
onTap: () => _openPhotoViewer(context, cubit, i),
|
||||||
|
// Decode to the 140px thumbnail size (× dpr), not full photo
|
||||||
|
// resolution — the full-res decode happens in the viewer.
|
||||||
child: Image.memory(
|
child: Image.memory(
|
||||||
photos[i].bytes,
|
photos[i].bytes,
|
||||||
width: 140,
|
width: 140,
|
||||||
height: 140,
|
height: 140,
|
||||||
|
cacheWidth:
|
||||||
|
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
|
cacheHeight:
|
||||||
|
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ void main() {
|
||||||
});
|
});
|
||||||
tearDown(() => db.close());
|
tearDown(() => db.close());
|
||||||
|
|
||||||
test('the inventory view loads 3000 varieties quickly', () async {
|
test('the inventory view loads 10000 varieties quickly', () async {
|
||||||
const count = 3000;
|
const count = 10000;
|
||||||
for (var i = 0; i < count; i++) {
|
for (var i = 0; i < count; i++) {
|
||||||
final id = await repo.addQuickVariety(
|
final id = await repo.addQuickVariety(
|
||||||
label: 'Variety $i',
|
label: 'Variety $i',
|
||||||
|
|
@ -43,11 +43,12 @@ void main() {
|
||||||
sw.stop();
|
sw.stop();
|
||||||
|
|
||||||
expect(view.items, hasLength(count));
|
expect(view.items, hasLength(count));
|
||||||
// Generous ceiling for CI; typical local runs are well under 500ms. The
|
// Generous ceiling for CI (the point is to catch an N+1 regression, not to
|
||||||
// point is to catch an accidental N+1 regression, not to micro-benchmark.
|
// micro-benchmark). Includes the ~250ms debounce on the first emit; typical
|
||||||
|
// local runs are well under 2s for 10k rows with the v14 indexes.
|
||||||
expect(
|
expect(
|
||||||
sw.elapsedMilliseconds,
|
sw.elapsedMilliseconds,
|
||||||
lessThan(3000),
|
lessThan(6000),
|
||||||
reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows',
|
reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,19 @@ void main() {
|
||||||
verifier = SchemaVerifier(GeneratedHelper());
|
verifier = SchemaVerifier(GeneratedHelper());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('freshly created database matches the exported schema v13', () async {
|
test('freshly created database matches the exported schema v14', () async {
|
||||||
final schema = await verifier.schemaAt(13);
|
final schema = await verifier.schemaAt(14);
|
||||||
final db = AppDatabase(schema.newConnection());
|
final db = AppDatabase(schema.newConnection());
|
||||||
await verifier.migrateAndValidate(db, 13);
|
await verifier.migrateAndValidate(db, 14);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Every historical version upgrades cleanly to the current schema (v13).
|
// Every historical version upgrades cleanly to the current schema (v14).
|
||||||
for (var from = 1; from <= 12; from++) {
|
for (var from = 1; from <= 13; from++) {
|
||||||
test('upgrades v$from → v13 and matches the fresh schema', () async {
|
test('upgrades v$from → v14 and matches the fresh schema', () async {
|
||||||
final connection = await verifier.startAt(from);
|
final connection = await verifier.startAt(from);
|
||||||
final db = AppDatabase(connection);
|
final db = AppDatabase(connection);
|
||||||
await verifier.migrateAndValidate(db, 13);
|
await verifier.migrateAndValidate(db, 14);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import 'schema_v10.dart' as v10;
|
||||||
import 'schema_v11.dart' as v11;
|
import 'schema_v11.dart' as v11;
|
||||||
import 'schema_v12.dart' as v12;
|
import 'schema_v12.dart' as v12;
|
||||||
import 'schema_v13.dart' as v13;
|
import 'schema_v13.dart' as v13;
|
||||||
|
import 'schema_v14.dart' as v14;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
|
|
@ -48,10 +49,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
return v12.DatabaseAtV12(db);
|
return v12.DatabaseAtV12(db);
|
||||||
case 13:
|
case 13:
|
||||||
return v13.DatabaseAtV13(db);
|
return v13.DatabaseAtV13(db);
|
||||||
|
case 14:
|
||||||
|
return v14.DatabaseAtV14(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2248
apps/app_seeds/test/db/schema/schema_v14.dart
Normal file
2248
apps/app_seeds/test/db/schema/schema_v14.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit {
|
||||||
/// A transport that is present (so the market reads as online) but never used.
|
/// A transport that is present (so the market reads as online) but never used.
|
||||||
class _NoopOfferTransport implements OfferTransport {
|
class _NoopOfferTransport implements OfferTransport {
|
||||||
@override
|
@override
|
||||||
Stream<Offer> discover(DiscoveryQuery query) => const Stream.empty();
|
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
|
||||||
|
const Stream.empty();
|
||||||
|
@override
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async =>
|
||||||
|
const OfferPage(offers: []);
|
||||||
@override
|
@override
|
||||||
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
|
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class FakeOfferTransport implements OfferTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<Offer> discover(DiscoveryQuery query) {
|
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
|
||||||
final controller = StreamController<Offer>();
|
final controller = StreamController<Offer>();
|
||||||
for (final o in _offers) {
|
for (final o in _offers) {
|
||||||
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
|
||||||
|
|
@ -33,6 +33,15 @@ class FakeOfferTransport implements OfferTransport {
|
||||||
return controller.stream; // left open (live), like a real subscription
|
return controller.stream; // left open (live), like a real subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
||||||
|
final matches = [
|
||||||
|
for (final o in _offers)
|
||||||
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
|
||||||
|
];
|
||||||
|
return OfferPage(offers: matches); // short page → nothing older to load
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> retract(String offerId) async =>
|
Future<void> retract(String offerId) async =>
|
||||||
_offers.removeWhere((o) => o.id == offerId);
|
_offers.removeWhere((o) => o.id == offerId);
|
||||||
|
|
@ -54,7 +63,7 @@ class DuplicatingOfferTransport implements OfferTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<Offer> discover(DiscoveryQuery query) {
|
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
|
||||||
final controller = StreamController<Offer>();
|
final controller = StreamController<Offer>();
|
||||||
for (final o in _offers) {
|
for (final o in _offers) {
|
||||||
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
|
||||||
|
|
@ -65,6 +74,17 @@ class DuplicatingOfferTransport implements OfferTransport {
|
||||||
return controller.stream;
|
return controller.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
||||||
|
// The stored copy (deduped at relay level); the live echo (the duplicate)
|
||||||
|
// still arrives via [discover], so the cubit's dedup is what's under test.
|
||||||
|
final matches = [
|
||||||
|
for (final o in _offers)
|
||||||
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
|
||||||
|
];
|
||||||
|
return OfferPage(offers: matches);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> retract(String offerId) async =>
|
Future<void> retract(String offerId) async =>
|
||||||
_offers.removeWhere((o) => o.id == offerId);
|
_offers.removeWhere((o) => o.id == offerId);
|
||||||
|
|
@ -73,6 +93,59 @@ class DuplicatingOfferTransport implements OfferTransport {
|
||||||
Future<void> close() async {}
|
Future<void> close() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A transport that serves offers in `until`-cursored pages (like a relay's
|
||||||
|
/// stored events), so the cubit's pagination and memory cap can be exercised.
|
||||||
|
/// Each seeded offer gets a descending createdAt; [discover] (live) is empty.
|
||||||
|
class PaginatingOfferTransport implements OfferTransport {
|
||||||
|
PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) {
|
||||||
|
// Newest (highest createdAt) first: offer 0 is newest.
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
_byCreatedAt.add((
|
||||||
|
createdAt: 1000000 - i,
|
||||||
|
offer: Offer(
|
||||||
|
id: 'o$i',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'offer $i',
|
||||||
|
type: OfferType.gift,
|
||||||
|
approxGeohash: geohash,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String geohash;
|
||||||
|
final List<({int createdAt, Offer offer})> _byCreatedAt = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
|
||||||
|
const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
||||||
|
final matched = _byCreatedAt
|
||||||
|
.where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix))
|
||||||
|
.where((e) => query.until == null || e.createdAt <= query.until!)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
final page = matched.take(query.limit).toList();
|
||||||
|
final nextCursor = page.length >= query.limit && page.isNotEmpty
|
||||||
|
? page.last.createdAt - 1
|
||||||
|
: null;
|
||||||
|
return OfferPage(
|
||||||
|
offers: [for (final e in page) e.offer],
|
||||||
|
nextCursor: nextCursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PublishResult> publish(Offer offer) async =>
|
||||||
|
PublishResult(accepted: true, transportRef: offer.id);
|
||||||
|
@override
|
||||||
|
Future<void> retract(String offerId) async {}
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('OfferMapper', () {
|
group('OfferMapper', () {
|
||||||
test('maps local sharing intent to the network offer type', () {
|
test('maps local sharing intent to the network offer type', () {
|
||||||
|
|
@ -701,4 +774,65 @@ void main() {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('OffersCubit pagination', () {
|
||||||
|
test('discover loads the first page and exposes a next-page cursor',
|
||||||
|
() async {
|
||||||
|
final cubit = OffersCubit(PaginatingOfferTransport(250));
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
// One page (default limit 100) — not the whole 250 — is kept in memory.
|
||||||
|
expect(cubit.state.offers, hasLength(100));
|
||||||
|
expect(cubit.state.canLoadMore, isTrue);
|
||||||
|
expect(cubit.state.searching, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadNextPage accumulates older offers until the source is exhausted',
|
||||||
|
() async {
|
||||||
|
final cubit = OffersCubit(PaginatingOfferTransport(250));
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
await cubit.loadNextPage();
|
||||||
|
expect(cubit.state.offers, hasLength(200));
|
||||||
|
expect(cubit.state.canLoadMore, isTrue);
|
||||||
|
|
||||||
|
await cubit.loadNextPage();
|
||||||
|
// Only 250 exist: the third page is short, so there is nothing older.
|
||||||
|
expect(cubit.state.offers, hasLength(250));
|
||||||
|
expect(cubit.state.canLoadMore, isFalse);
|
||||||
|
|
||||||
|
// Paging past the end is a harmless no-op.
|
||||||
|
await cubit.loadNextPage();
|
||||||
|
expect(cubit.state.offers, hasLength(250));
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the in-memory list is capped and paging stops at the cap', () async {
|
||||||
|
final cubit = OffersCubit(PaginatingOfferTransport(600));
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
// Keep paging until the cubit says there is no more.
|
||||||
|
var guard = 0;
|
||||||
|
while (cubit.state.canLoadMore && guard++ < 20) {
|
||||||
|
await cubit.loadNextPage();
|
||||||
|
}
|
||||||
|
expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept));
|
||||||
|
expect(cubit.state.canLoadMore, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers are de-duplicated across pages by (author, id)', () async {
|
||||||
|
// Two pages that overlap on the boundary id must not double it.
|
||||||
|
final cubit = OffersCubit(PaginatingOfferTransport(150));
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
await cubit.loadNextPage();
|
||||||
|
final ids = cubit.state.offers.map((o) => o.id).toList();
|
||||||
|
expect(ids.toSet(), hasLength(ids.length)); // no duplicates
|
||||||
|
expect(cubit.state.offers, hasLength(150));
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
apps/app_seeds/test/ui/inventory_list_lazy_test.dart
Normal file
45
apps/app_seeds/test/ui/inventory_list_lazy_test.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/ui/inventory_list_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// The inventory list must render lazily: with a large catalogue only the tiles
|
||||||
|
/// near the viewport are built, not one widget per row. Guards the regression
|
||||||
|
/// from `ListView(children: …)` (every tile built upfront) back in.
|
||||||
|
void main() {
|
||||||
|
testWidgets('builds only the on-screen tiles for a large inventory',
|
||||||
|
(tester) async {
|
||||||
|
final db = newTestDatabase();
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
// Enough rows that an eager list would build hundreds of tiles at once.
|
||||||
|
for (var i = 0; i < 300; i++) {
|
||||||
|
await repo.addQuickVariety(
|
||||||
|
label: 'Variety ${i.toString().padLeft(3, '0')}',
|
||||||
|
category: i.isEven ? 'Poaceae' : 'Fabaceae',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrapScreen(repository: repo, child: const InventoryListScreen()),
|
||||||
|
);
|
||||||
|
// Let the debounced inventory stream emit and the async load resolve
|
||||||
|
// (bounded pumps — the screen holds a live Drift stream, so pumpAndSettle
|
||||||
|
// would hang; see testing.md).
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Every variety tile is a ListTile; a lazy list builds only a viewport-worth.
|
||||||
|
final built = find.byType(ListTile).evaluate().length;
|
||||||
|
expect(built, greaterThan(0), reason: 'the list rendered some tiles');
|
||||||
|
expect(
|
||||||
|
built,
|
||||||
|
lessThan(50),
|
||||||
|
reason: 'lazy list should build ~a screenful, not all 300 ($built built)',
|
||||||
|
);
|
||||||
|
|
||||||
|
await disposeTree(tester);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,11 @@ git tag v0.1.0 && git push origin v0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Forgejo Actions ([`../.forgejo/workflows/release.yml`](../.forgejo/workflows/release.yml))
|
Forgejo Actions ([`../.forgejo/workflows/release.yml`](../.forgejo/workflows/release.yml))
|
||||||
builds the signed AAB/APK and uploads to Play's **internal** track. No passwords are typed.
|
builds the signed AAB/APK and uploads to Play's **production** track at **100%
|
||||||
|
rollout**. No passwords are typed. Because a tag now publishes to everyone
|
||||||
|
(subject to Google review), run the [manual smoke test](#manual-smoke-test-before-shipping)
|
||||||
|
**before** tagging. To stage on the internal track first, run
|
||||||
|
`bundle exec fastlane deploy_internal` by hand.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
|
|
@ -123,6 +127,24 @@ listing (titles, descriptions, changelogs, screenshots) is read straight from
|
||||||
`fastlane/metadata/android/`. Data Safety and content-rating answers:
|
`fastlane/metadata/android/`. Data Safety and content-rating answers:
|
||||||
[`legal/internal/play-compliance.md`](legal/internal/play-compliance.md).
|
[`legal/internal/play-compliance.md`](legal/internal/play-compliance.md).
|
||||||
|
|
||||||
|
Lanes:
|
||||||
|
- `deploy_play` — upload the AAB to **production** at 100% (what CI runs on tag).
|
||||||
|
- `deploy_internal` — same AAB to the **internal** test track (manual QA safety net).
|
||||||
|
- `deploy_metadata` — push only the store listing (no binary).
|
||||||
|
- `promote_production` — promote an internal release to production without a rebuild.
|
||||||
|
|
||||||
|
### Country/region availability (Play Console, not this repo)
|
||||||
|
|
||||||
|
Which countries the app is offered in is **not** in the repo and `fastlane supply`
|
||||||
|
does not manage it — it's a Play Console setting. To add countries:
|
||||||
|
**Play Console → (Tane) → Production → Countries / regions → Add countries/regions**
|
||||||
|
(under "Availability"). Play asks you to pick countries when you first move a
|
||||||
|
release to production; widen the list there for new markets.
|
||||||
|
|
||||||
|
Note: the locales under `fastlane/metadata/android/<locale>` (`en-US`, `es-ES`)
|
||||||
|
are **listing languages**, not countries — adding a locale improves the store
|
||||||
|
page but does not by itself make the app available in a new country.
|
||||||
|
|
||||||
## Publish to F-Droid (official repo)
|
## Publish to F-Droid (official repo)
|
||||||
|
|
||||||
F-Droid builds from source after a merge request to `fdroiddata`. The build recipe
|
F-Droid builds from source after a merge request to `fdroiddata`. The build recipe
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@ class NostrOfferTransport implements OfferTransport {
|
||||||
final NostrChannel _conn;
|
final NostrChannel _conn;
|
||||||
final Nip99Codec _codec = Nip99Codec();
|
final Nip99Codec _codec = Nip99Codec();
|
||||||
|
|
||||||
Filter _filter(DiscoveryQuery q) => Filter(
|
Filter _filter(DiscoveryQuery q, {int? since}) => Filter(
|
||||||
kinds: const [Nip99Codec.kindActive],
|
kinds: const [Nip99Codec.kindActive],
|
||||||
tagFilters: {'g': [q.geohashPrefix]},
|
tagFilters: {'g': [q.geohashPrefix]},
|
||||||
limit: q.limit,
|
limit: q.limit,
|
||||||
|
since: since,
|
||||||
|
until: q.until,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -37,11 +39,34 @@ class NostrOfferTransport implements OfferTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<Offer> discover(DiscoveryQuery query) => _conn
|
Stream<Offer> discover(DiscoveryQuery query, {int? since}) => _conn
|
||||||
.subscribe(_filter(query))
|
.subscribe(_filter(query, since: since))
|
||||||
.map(_codec.decode)
|
.map(_codec.decode)
|
||||||
.where((o) => query.types.isEmpty || query.types.contains(o.type));
|
.where((o) => query.types.isEmpty || query.types.contains(o.type));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
||||||
|
final events = await _conn.reqOnce(_filter(query))
|
||||||
|
// Newest first; the relays dedup within themselves and reqOnce dedups
|
||||||
|
// across them, but order is not guaranteed, so sort here.
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
final offers = <Offer>[];
|
||||||
|
for (final e in events) {
|
||||||
|
final offer = _codec.decode(e);
|
||||||
|
if (_matchesType(offer, query)) offers.add(offer);
|
||||||
|
}
|
||||||
|
// A full page means more may exist older than the oldest event we saw; a
|
||||||
|
// short page means we've reached the end. Base the cursor on ALL events
|
||||||
|
// (not just type-matched ones) so type filtering never makes us re-fetch.
|
||||||
|
final nextCursor = events.length >= query.limit && events.isNotEmpty
|
||||||
|
? events.last.createdAt - 1
|
||||||
|
: null;
|
||||||
|
return OfferPage(offers: offers, nextCursor: nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesType(Offer o, DiscoveryQuery q) =>
|
||||||
|
q.types.isEmpty || q.types.contains(o.type);
|
||||||
|
|
||||||
/// Collects matches up to EOSE (tests/one-shot browse).
|
/// Collects matches up to EOSE (tests/one-shot browse).
|
||||||
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
||||||
final events = await _conn.reqOnce(_filter(query));
|
final events = await _conn.reqOnce(_filter(query));
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,28 @@ class DiscoveryQuery {
|
||||||
required this.geohashPrefix,
|
required this.geohashPrefix,
|
||||||
this.types = const {},
|
this.types = const {},
|
||||||
this.limit = 100,
|
this.limit = 100,
|
||||||
|
this.until,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km).
|
/// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km).
|
||||||
final String geohashPrefix;
|
final String geohashPrefix;
|
||||||
final Set<OfferType> types;
|
final Set<OfferType> types;
|
||||||
final int limit;
|
final int limit;
|
||||||
|
|
||||||
|
/// Pagination cursor (NIP-01 `until`): return only offers published at or
|
||||||
|
/// before this Unix time (seconds). Null asks for the newest page. Comes from
|
||||||
|
/// the previous page's [OfferPage.nextCursor].
|
||||||
|
final int? until;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of discovered offers plus the cursor to fetch the next (older) page.
|
||||||
|
class OfferPage {
|
||||||
|
const OfferPage({required this.offers, this.nextCursor});
|
||||||
|
|
||||||
|
/// The page's offers, newest first.
|
||||||
|
final List<Offer> offers;
|
||||||
|
|
||||||
|
/// Pass as the next query's [DiscoveryQuery.until] to page further back. Null
|
||||||
|
/// when the relays returned less than a full page — there is nothing older.
|
||||||
|
final int? nextCursor;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,16 @@ abstract interface class OfferTransport {
|
||||||
Future<PublishResult> publish(Offer offer);
|
Future<PublishResult> publish(Offer offer);
|
||||||
|
|
||||||
/// Streams offers matching [query]: stored matches first, then live ones,
|
/// Streams offers matching [query]: stored matches first, then live ones,
|
||||||
/// until the caller cancels the subscription.
|
/// until the caller cancels the subscription. Pass [since] (Unix seconds) to
|
||||||
Stream<Offer> discover(DiscoveryQuery query);
|
/// stream only offers published after it — used to keep a live subscription
|
||||||
|
/// for NEW offers while older ones are browsed via [discoverPage].
|
||||||
|
Stream<Offer> discover(DiscoveryQuery query, {int? since});
|
||||||
|
|
||||||
|
/// Fetches ONE page of stored offers matching [query] (up to EOSE), newest
|
||||||
|
/// first, with a cursor to page further back. Bounded — unlike [discover] it
|
||||||
|
/// does not hold a live subscription — so the UI can scroll a large result
|
||||||
|
/// set without accumulating every offer in memory.
|
||||||
|
Future<OfferPage> discoverPage(DiscoveryQuery query);
|
||||||
|
|
||||||
/// Retracts a previously published offer (relays that already replicated it
|
/// Retracts a previously published offer (relays that already replicated it
|
||||||
/// drop it over time).
|
/// drop it over time).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:nostr/nostr.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A stand-in relay channel: [reqOnce] honours the filter's `until` and `limit`
|
||||||
|
/// (newest first), like a relay serving stored events, so [discoverPage]'s
|
||||||
|
/// ordering and cursor maths can be asserted deterministically.
|
||||||
|
class _FakeChannel implements NostrChannel {
|
||||||
|
_FakeChannel(this._events);
|
||||||
|
|
||||||
|
final List<Event> _events;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Event>> reqOnce(Filter filter) async {
|
||||||
|
final until = filter.until;
|
||||||
|
final matched = _events
|
||||||
|
.where((e) => until == null || e.createdAt <= until)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
return matched.take(filter.limit ?? matched.length).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get privateKeyHex => '00' * 32;
|
||||||
|
@override
|
||||||
|
String get publicKeyHex => 'ab' * 32;
|
||||||
|
@override
|
||||||
|
Future<({bool accepted, String message})> publish(Event event) async =>
|
||||||
|
(accepted: true, message: '');
|
||||||
|
@override
|
||||||
|
Stream<Event> subscribe(Filter filter) => const Stream.empty();
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Event _evt(String id, int createdAt, {String type = 'gift'}) => Event(
|
||||||
|
'evt-$id',
|
||||||
|
'ab' * 32,
|
||||||
|
createdAt,
|
||||||
|
Nip99Codec.kindActive,
|
||||||
|
[
|
||||||
|
['d', id],
|
||||||
|
['g', 'sp3e9'],
|
||||||
|
['offer_type', type],
|
||||||
|
['title', 'offer $id'],
|
||||||
|
],
|
||||||
|
'offer $id',
|
||||||
|
'00' * 64,
|
||||||
|
verify: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('NostrOfferTransport.discoverPage', () {
|
||||||
|
test('returns offers newest-first with a cursor to the older page',
|
||||||
|
() async {
|
||||||
|
final transport = NostrOfferTransport(_FakeChannel([
|
||||||
|
_evt('a', 100), // oldest
|
||||||
|
_evt('c', 300), // newest
|
||||||
|
_evt('b', 200),
|
||||||
|
]));
|
||||||
|
|
||||||
|
final page1 = await transport.discoverPage(
|
||||||
|
const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2),
|
||||||
|
);
|
||||||
|
// A full page (2 of the 3) sorted newest-first, with a cursor set.
|
||||||
|
expect(page1.offers.map((o) => o.id), ['c', 'b']);
|
||||||
|
expect(page1.nextCursor, 199, reason: 'oldest kept (200) minus one');
|
||||||
|
|
||||||
|
final page2 = await transport.discoverPage(
|
||||||
|
DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2, until: page1.nextCursor),
|
||||||
|
);
|
||||||
|
// Only the last one remains — a short page, so nothing older.
|
||||||
|
expect(page2.offers.map((o) => o.id), ['a']);
|
||||||
|
expect(page2.nextCursor, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short first page reports no next cursor', () async {
|
||||||
|
final transport = NostrOfferTransport(_FakeChannel([
|
||||||
|
_evt('a', 100),
|
||||||
|
_evt('b', 200),
|
||||||
|
]));
|
||||||
|
final page = await transport.discoverPage(
|
||||||
|
const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 100),
|
||||||
|
);
|
||||||
|
expect(page.offers, hasLength(2));
|
||||||
|
expect(page.nextCursor, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type filtering narrows the offers but not the cursor', () async {
|
||||||
|
// A full page of gifts with one sale mixed in; asking for sales only must
|
||||||
|
// still advance the cursor by the oldest EVENT seen, not the oldest match,
|
||||||
|
// so paging never skips unmatched offers.
|
||||||
|
final transport = NostrOfferTransport(_FakeChannel([
|
||||||
|
_evt('g1', 300),
|
||||||
|
_evt('s1', 200, type: 'sale'),
|
||||||
|
_evt('g2', 100),
|
||||||
|
]));
|
||||||
|
final page = await transport.discoverPage(
|
||||||
|
const DiscoveryQuery(
|
||||||
|
geohashPrefix: 'sp3e9',
|
||||||
|
limit: 3,
|
||||||
|
types: {OfferType.sale},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(page.offers.map((o) => o.id), ['s1']);
|
||||||
|
expect(page.nextCursor, 99, reason: 'oldest event (100) minus one');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue