diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 7f0bf5b..ab25c21 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -8,6 +8,7 @@ import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; import 'services/coarse_location.dart'; +import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -33,6 +34,7 @@ class TaneApp extends StatelessWidget { this.social, this.socialSettings, this.location, + this.outbox, this.showIntro = false, super.key, }) : _router = _buildRouter( @@ -42,6 +44,7 @@ class TaneApp extends StatelessWidget { social, socialSettings, location, + outbox, ); final VarietyRepository repository; @@ -55,6 +58,9 @@ class TaneApp extends StatelessWidget { /// Optional device-location source for the market's "use my location". final CoarseLocationProvider? location; + + /// Optional offline outbox for the market's "share my seeds". + final OfferOutbox? outbox; final bool showIntro; final GoRouter _router; @@ -65,6 +71,7 @@ class TaneApp extends StatelessWidget { SocialService? social, SocialSettings? socialSettings, CoarseLocationProvider? location, + OfferOutbox? outbox, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', @@ -81,6 +88,7 @@ class TaneApp extends StatelessWidget { social: social, settings: socialSettings, location: location, + outbox: outbox, ), ), GoRoute( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 37bb62e..a3df666 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -24,6 +24,7 @@ import '../services/ocr/tesseract_label_extractor.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; +import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -85,6 +86,7 @@ Future configureDependencies() async { ..registerSingleton(OnboardingStore(secretStore)) ..registerSingleton(socialService) ..registerSingleton(SocialSettings(secretStore)) + ..registerSingleton(OfferOutbox(secretStore)) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index a2360d0..c70c8df 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -353,6 +353,7 @@ "sharedCount": "Shared {n} seeds nearby", "nothingToShare": "Mark some seeds to give, swap or sell first", "useLocation": "Use my approximate location", - "locationFailed": "Couldn't get your location" + "locationFailed": "Couldn't get your location", + "queued": "Saved — we'll share these when you're connected" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 88c9b91..dd923d5 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -353,6 +353,7 @@ "sharedCount": "Compartidas {n} semillas", "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", "useLocation": "Usar mi ubicación aproximada", - "locationFailed": "No se pudo obtener tu ubicación" + "locationFailed": "No se pudo obtener tu ubicación", + "queued": "Guardado — las compartiremos cuando tengas conexión" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 49fca66..0cf6bd5 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -349,6 +349,7 @@ "sharedCount": "Partilhadas {n} sementes", "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender", "useLocation": "Usar a minha localização aproximada", - "locationFailed": "Não foi possível obter a tua localização" + "locationFailed": "Não foi possível obter a tua localização", + "queued": "Guardado — vamos partilhá-las quando tiveres ligação" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 8158e2a..29d7575 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 944 (314 per locale) +/// Strings: 947 (315 per locale) /// -/// Built on 2026-07-10 at 08:25 UTC +/// Built on 2026-07-10 at 08:44 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 2b7f1b2..dfa3eb2 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1120,6 +1120,9 @@ class Translations$market$en { /// en: 'Couldn't get your location' String get locationFailed => 'Couldn\'t get your location'; + + /// en: 'Saved — we'll share these when you're connected' + String get queued => 'Saved — we\'ll share these when you\'re connected'; } // Path: intro.slides @@ -1895,6 +1898,7 @@ extension on Translations { 'market.nothingToShare' => 'Mark some seeds to give, swap or sell first', 'market.useLocation' => 'Use my approximate location', 'market.locationFailed' => 'Couldn\'t get your location', + 'market.queued' => 'Saved — we\'ll share these when you\'re connected', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 746cab2..cac6e39 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -624,6 +624,7 @@ class _Translations$market$es extends Translations$market$en { @override String get nothingToShare => 'Marca antes algunas semillas para regalar, cambiar o vender'; @override String get useLocation => 'Usar mi ubicación aproximada'; @override String get locationFailed => 'No se pudo obtener tu ubicación'; + @override String get queued => 'Guardado — las compartiremos cuando tengas conexión'; } // Path: intro.slides @@ -1283,6 +1284,7 @@ extension on TranslationsEs { 'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender', 'market.useLocation' => 'Usar mi ubicación aproximada', 'market.locationFailed' => 'No se pudo obtener tu ubicación', + 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 877939a..fd5b831 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -620,6 +620,7 @@ class _Translations$market$pt extends Translations$market$en { @override String get nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender'; @override String get useLocation => 'Usar a minha localização aproximada'; @override String get locationFailed => 'Não foi possível obter a tua localização'; + @override String get queued => 'Guardado — vamos partilhá-las quando tiveres ligação'; } // Path: intro.slides @@ -1275,6 +1276,7 @@ extension on TranslationsPt { 'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender', 'market.useLocation' => 'Usar a minha localização aproximada', 'market.locationFailed' => 'Não foi possível obter a tua localização', + 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', _ => null, }; } diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 5dfe497..48d3e24 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -6,6 +6,7 @@ import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; import 'services/coarse_location.dart'; +import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -24,6 +25,7 @@ Future main() async { social: getIt(), socialSettings: getIt(), location: const GeolocatorCoarseLocation(), + outbox: getIt(), showIntro: !await onboarding.introSeen(), ), ), diff --git a/apps/app_seeds/lib/services/offer_outbox.dart b/apps/app_seeds/lib/services/offer_outbox.dart new file mode 100644 index 0000000..f952d6d --- /dev/null +++ b/apps/app_seeds/lib/services/offer_outbox.dart @@ -0,0 +1,37 @@ +import '../security/secret_store.dart'; + +/// A durable "to publish when connected" queue of lot ids. When sharing is +/// attempted offline, the lot ids are parked here (keystore-backed, so no +/// plaintext at rest) and flushed once a relay is reachable — offline delivery +/// for the sender side. +/// +/// We store lot ids, not serialized offers: on flush the offer is rebuilt from +/// the lot's current state, so an edit made while offline is respected and a +/// since-deleted lot simply drops out. +class OfferOutbox { + OfferOutbox(this._store); + + final SecretStore _store; + static const _key = 'tane.social.outbox'; + + /// Lot ids waiting to be published. + Future> pending() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return {}; + return raw.split('\n').where((s) => s.isNotEmpty).toSet(); + } + + Future enqueue(Iterable lotIds) async { + final next = await pending()..addAll(lotIds); + await _write(next); + } + + Future remove(Iterable lotIds) async { + final next = await pending()..removeAll(lotIds.toSet()); + await _write(next); + } + + Future clear() => _store.write(_key, ''); + + Future _write(Set ids) => _store.write(_key, ids.join('\n')); +} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 42acc4b..f9b2d46 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../services/offer_mapper.dart'; +import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -178,3 +179,31 @@ Future createOffersCubit( return OffersCubit(null); // offline / no relay reachable } } + +/// Publishes any queued (offline-parked) lots now that we're online, then clears +/// them from the [outbox]. Rebuilds each offer from the lot's CURRENT state, so +/// since-deleted or now-private lots simply drop out. Returns how many published. +Future flushOutbox({ + required OfferOutbox outbox, + required OffersCubit cubit, + required List shareableLots, + required String authorPubkeyHex, + required String areaGeohash, +}) async { + if (!cubit.isOnline || areaGeohash.isEmpty) return 0; + final queued = await outbox.pending(); + if (queued.isEmpty) return 0; + final toPublish = + shareableLots.where((l) => queued.contains(l.lotId)).toList(); + var published = 0; + if (toPublish.isNotEmpty) { + published = await cubit.publishLots( + toPublish, + authorPubkeyHex: authorPubkeyHex, + areaGeohash: areaGeohash, + ); + } + // Clear everything attempted (or gone) so we don't loop on it. + await outbox.remove(queued); + return published; +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 395ba1d..fce2ab7 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../services/coarse_location.dart'; +import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.dart'; @@ -18,6 +19,7 @@ class MarketScreen extends StatefulWidget { required this.social, required this.settings, this.location, + this.outbox, super.key, }); @@ -28,6 +30,10 @@ class MarketScreen extends StatefulWidget { /// null (tests, or a platform without it) the shortcut is hidden. final CoarseLocationProvider? location; + /// Optional offline outbox: parks shares attempted with no network and flushes + /// them on reconnect. Null in tests → sharing offline is simply a no-op. + final OfferOutbox? outbox; + @override State createState() => _MarketScreenState(); } @@ -43,6 +49,10 @@ class _MarketScreenState extends State { } Future _init() async { + // Only needed to flush the outbox; read here (while mounted) to avoid using + // context across the awaits below. Null when there's no outbox (e.g. tests). + final repo = + widget.outbox != null ? context.read() : null; setState(() => _loading = true); final cubit = await createOffersCubit(widget.social, widget.settings); final area = await widget.settings.areaGeohash(); @@ -56,7 +66,20 @@ class _MarketScreenState extends State { _loading = false; }); await previous?.close(); - if (area.isNotEmpty && cubit.isOnline) await cubit.discover(area); + if (area.isNotEmpty && cubit.isOnline) { + // Flush anything parked while offline, then show what's out there. + final outbox = widget.outbox; + if (outbox != null && repo != null) { + await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: await repo.shareableLots(), + authorPubkeyHex: widget.social.publicKeyHex, + areaGeohash: area, + ); + } + if (mounted) await cubit.discover(area); + } } Future _openConfig() async { @@ -73,7 +96,7 @@ class _MarketScreenState extends State { /// coarse area. Prompts for setup when the area isn't set. Future _shareMine() async { final cubit = _cubit; - if (cubit == null || !cubit.isOnline) return; + if (cubit == null) return; final repo = context.read(); final messenger = ScaffoldMessenger.of(context); final t = context.t; @@ -89,12 +112,23 @@ class _MarketScreenState extends State { messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare))); return; } + final ids = lots.map((l) => l.lotId).toList(); + + // Offline: park it and tell the person we'll share it later. + if (!cubit.isOnline) { + await widget.outbox?.enqueue(ids); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(t.market.queued))); + return; + } + final count = await cubit.publishLots( lots, authorPubkeyHex: widget.social.publicKeyHex, areaGeohash: area, ); if (!mounted) return; + await widget.outbox?.remove(ids); // published now, so unpark any duplicates messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count)))); await cubit.discover(area); // refresh — now including the just-shared lots } @@ -113,7 +147,7 @@ class _MarketScreenState extends State { appBar: AppBar( title: Text(t.market.title), actions: [ - if (cubit != null && cubit.isOnline) + if (cubit != null && (cubit.isOnline || widget.outbox != null)) IconButton( key: const Key('market.shareMine'), icon: const Icon(Icons.ios_share_outlined), diff --git a/apps/app_seeds/test/services/offer_outbox_test.dart b/apps/app_seeds/test/services/offer_outbox_test.dart new file mode 100644 index 0000000..20abd71 --- /dev/null +++ b/apps/app_seeds/test/services/offer_outbox_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/offer_outbox.dart'; + +import '../support/test_support.dart'; + +void main() { + late OfferOutbox outbox; + setUp(() => outbox = OfferOutbox(InMemorySecretStore())); + + test('starts empty', () async { + expect(await outbox.pending(), isEmpty); + }); + + test('enqueue adds ids; it is a set (no duplicates)', () async { + await outbox.enqueue(['a', 'b']); + await outbox.enqueue(['b', 'c']); + expect(await outbox.pending(), {'a', 'b', 'c'}); + }); + + test('remove drops the given ids', () async { + await outbox.enqueue(['a', 'b', 'c']); + await outbox.remove(['b']); + expect(await outbox.pending(), {'a', 'c'}); + }); + + test('clear empties it', () async { + await outbox.enqueue(['a', 'b']); + await outbox.clear(); + expect(await outbox.pending(), isEmpty); + }); +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index a867b49..b8800e1 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -5,8 +5,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/services/offer_mapper.dart'; +import 'package:tane/services/offer_outbox.dart'; import 'package:tane/state/offers_cubit.dart'; +import '../support/test_support.dart'; + /// In-memory [OfferTransport] for tests: addressable by id, discover matches by /// geohash prefix. No relay, no network. class FakeOfferTransport implements OfferTransport { @@ -196,6 +199,63 @@ void main() { await noArea.close(); }); + test('flushOutbox publishes queued lots, then clears them', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['lot-1', 'lot-2']); + final cubit = OffersCubit(FakeOfferTransport()); + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [ + ShareableLot( + lotId: 'lot-1', summary: 'Tomate', offerStatus: OfferStatus.shared), + ShareableLot( + lotId: 'lot-2', + summary: 'Judía', + offerStatus: OfferStatus.exchange), + ShareableLot( + lotId: 'lot-3', summary: 'Otro', offerStatus: OfferStatus.shared), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 2); + expect(await outbox.pending(), isEmpty); + await cubit.close(); + }); + + test('flushOutbox offline keeps the queue for later', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['lot-1']); + final cubit = OffersCubit(null); // offline + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 0); + expect(await outbox.pending(), {'lot-1'}); + await cubit.close(); + }); + + test('flushOutbox drops queued lots that no longer exist', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['gone']); + final cubit = OffersCubit(FakeOfferTransport()); + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 0); + expect(await outbox.pending(), isEmpty); + await cubit.close(); + }); + test('offline (no transport): degrades gracefully, never throws', () async { final cubit = OffersCubit(null); expect(cubit.isOnline, isFalse);