feat(block2): offline outbox — share now, publish when connected
Sender-side offline delivery hardening.
- OfferOutbox: a keystore-backed (no plaintext) queue of lot ids to publish
later. Stores ids, not offers, so on flush each offer is rebuilt from the
lot's CURRENT state — an offline edit is respected, a deleted lot drops out.
- flushOutbox(): on reconnect, republishes queued lots and clears them (no-op
offline; drops ids whose lot is gone).
- Market screen: sharing offline parks the lots ('we'll share these when you're
connected'); opening the market online auto-flushes the queue first.
- Wired via DI + TaneApp(outbox); i18n en/es/pt.
Tests: 4 outbox + 3 flush + market UI, 20 green. Analyzer clean for new code.
This commit is contained in:
parent
2a6b4b0947
commit
cf5d0243ee
15 changed files with 222 additions and 8 deletions
|
|
@ -8,6 +8,7 @@ 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/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
|
|
@ -33,6 +34,7 @@ class TaneApp extends StatelessWidget {
|
||||||
this.social,
|
this.social,
|
||||||
this.socialSettings,
|
this.socialSettings,
|
||||||
this.location,
|
this.location,
|
||||||
|
this.outbox,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) : _router = _buildRouter(
|
}) : _router = _buildRouter(
|
||||||
|
|
@ -42,6 +44,7 @@ class TaneApp extends StatelessWidget {
|
||||||
social,
|
social,
|
||||||
socialSettings,
|
socialSettings,
|
||||||
location,
|
location,
|
||||||
|
outbox,
|
||||||
);
|
);
|
||||||
|
|
||||||
final VarietyRepository repository;
|
final VarietyRepository repository;
|
||||||
|
|
@ -55,6 +58,9 @@ class TaneApp extends StatelessWidget {
|
||||||
|
|
||||||
/// Optional device-location source for the market's "use my location".
|
/// Optional device-location source for the market's "use my location".
|
||||||
final CoarseLocationProvider? location;
|
final CoarseLocationProvider? location;
|
||||||
|
|
||||||
|
/// Optional offline outbox for the market's "share my seeds".
|
||||||
|
final OfferOutbox? outbox;
|
||||||
final bool showIntro;
|
final bool showIntro;
|
||||||
final GoRouter _router;
|
final GoRouter _router;
|
||||||
|
|
||||||
|
|
@ -65,6 +71,7 @@ class TaneApp extends StatelessWidget {
|
||||||
SocialService? social,
|
SocialService? social,
|
||||||
SocialSettings? socialSettings,
|
SocialSettings? socialSettings,
|
||||||
CoarseLocationProvider? location,
|
CoarseLocationProvider? location,
|
||||||
|
OfferOutbox? outbox,
|
||||||
) {
|
) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: showIntro ? '/intro' : '/',
|
initialLocation: showIntro ? '/intro' : '/',
|
||||||
|
|
@ -81,6 +88,7 @@ class TaneApp extends StatelessWidget {
|
||||||
social: social,
|
social: social,
|
||||||
settings: socialSettings,
|
settings: socialSettings,
|
||||||
location: location,
|
location: location,
|
||||||
|
outbox: outbox,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import '../services/ocr/tesseract_label_extractor.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
import '../services/recovery_sheet_service.dart';
|
import '../services/recovery_sheet_service.dart';
|
||||||
import '../services/share_catalog_service.dart';
|
import '../services/share_catalog_service.dart';
|
||||||
|
import '../services/offer_outbox.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
|
|
||||||
|
|
@ -85,6 +86,7 @@ Future<void> configureDependencies() async {
|
||||||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||||
..registerSingleton<SocialService>(socialService)
|
..registerSingleton<SocialService>(socialService)
|
||||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||||
|
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||||
..registerSingleton<ExportImportService>(
|
..registerSingleton<ExportImportService>(
|
||||||
ExportImportService(
|
ExportImportService(
|
||||||
repository: varietyRepository,
|
repository: varietyRepository,
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@
|
||||||
"sharedCount": "Shared {n} seeds nearby",
|
"sharedCount": "Shared {n} seeds nearby",
|
||||||
"nothingToShare": "Mark some seeds to give, swap or sell first",
|
"nothingToShare": "Mark some seeds to give, swap or sell first",
|
||||||
"useLocation": "Use my approximate location",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@
|
||||||
"sharedCount": "Compartidas {n} semillas",
|
"sharedCount": "Compartidas {n} semillas",
|
||||||
"nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender",
|
"nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender",
|
||||||
"useLocation": "Usar mi ubicación aproximada",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,7 @@
|
||||||
"sharedCount": "Partilhadas {n} sementes",
|
"sharedCount": "Partilhadas {n} sementes",
|
||||||
"nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender",
|
"nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender",
|
||||||
"useLocation": "Usar a minha localização aproximada",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -1120,6 +1120,9 @@ class Translations$market$en {
|
||||||
|
|
||||||
/// en: 'Couldn't get your location'
|
/// en: 'Couldn't get your location'
|
||||||
String get locationFailed => '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
|
// Path: intro.slides
|
||||||
|
|
@ -1895,6 +1898,7 @@ extension on Translations {
|
||||||
'market.nothingToShare' => 'Mark some seeds to give, swap or sell first',
|
'market.nothingToShare' => 'Mark some seeds to give, swap or sell first',
|
||||||
'market.useLocation' => 'Use my approximate location',
|
'market.useLocation' => 'Use my approximate location',
|
||||||
'market.locationFailed' => 'Couldn\'t get your location',
|
'market.locationFailed' => 'Couldn\'t get your location',
|
||||||
|
'market.queued' => 'Saved — we\'ll share these when you\'re connected',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 nothingToShare => 'Marca antes algunas semillas para regalar, cambiar o vender';
|
||||||
@override String get useLocation => 'Usar mi ubicación aproximada';
|
@override String get useLocation => 'Usar mi ubicación aproximada';
|
||||||
@override String get locationFailed => 'No se pudo obtener tu ubicación';
|
@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
|
// Path: intro.slides
|
||||||
|
|
@ -1283,6 +1284,7 @@ extension on TranslationsEs {
|
||||||
'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender',
|
'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender',
|
||||||
'market.useLocation' => 'Usar mi ubicación aproximada',
|
'market.useLocation' => 'Usar mi ubicación aproximada',
|
||||||
'market.locationFailed' => 'No se pudo obtener tu ubicación',
|
'market.locationFailed' => 'No se pudo obtener tu ubicación',
|
||||||
|
'market.queued' => 'Guardado — las compartiremos cuando tengas conexión',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender';
|
||||||
@override String get useLocation => 'Usar a minha localização aproximada';
|
@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 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
|
// Path: intro.slides
|
||||||
|
|
@ -1275,6 +1276,7 @@ extension on TranslationsPt {
|
||||||
'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender',
|
'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender',
|
||||||
'market.useLocation' => 'Usar a minha localização aproximada',
|
'market.useLocation' => 'Usar a minha localização aproximada',
|
||||||
'market.locationFailed' => 'Não foi possível obter a tua localização',
|
'market.locationFailed' => 'Não foi possível obter a tua localização',
|
||||||
|
'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,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/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
|
|
@ -24,6 +25,7 @@ Future<void> main() async {
|
||||||
social: getIt<SocialService>(),
|
social: getIt<SocialService>(),
|
||||||
socialSettings: getIt<SocialSettings>(),
|
socialSettings: getIt<SocialSettings>(),
|
||||||
location: const GeolocatorCoarseLocation(),
|
location: const GeolocatorCoarseLocation(),
|
||||||
|
outbox: getIt<OfferOutbox>(),
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !await onboarding.introSeen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
37
apps/app_seeds/lib/services/offer_outbox.dart
Normal file
37
apps/app_seeds/lib/services/offer_outbox.dart
Normal file
|
|
@ -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<Set<String>> pending() async {
|
||||||
|
final raw = await _store.read(_key);
|
||||||
|
if (raw == null || raw.isEmpty) return <String>{};
|
||||||
|
return raw.split('\n').where((s) => s.isNotEmpty).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enqueue(Iterable<String> lotIds) async {
|
||||||
|
final next = await pending()..addAll(lotIds);
|
||||||
|
await _write(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(Iterable<String> lotIds) async {
|
||||||
|
final next = await pending()..removeAll(lotIds.toSet());
|
||||||
|
await _write(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() => _store.write(_key, '');
|
||||||
|
|
||||||
|
Future<void> _write(Set<String> ids) => _store.write(_key, ids.join('\n'));
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../services/offer_mapper.dart';
|
import '../services/offer_mapper.dart';
|
||||||
|
import '../services/offer_outbox.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
|
|
||||||
|
|
@ -178,3 +179,31 @@ Future<OffersCubit> createOffersCubit(
|
||||||
return OffersCubit(null); // offline / no relay reachable
|
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<int> flushOutbox({
|
||||||
|
required OfferOutbox outbox,
|
||||||
|
required OffersCubit cubit,
|
||||||
|
required List<ShareableLot> 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/coarse_location.dart';
|
import '../services/coarse_location.dart';
|
||||||
|
import '../services/offer_outbox.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../state/offers_cubit.dart';
|
import '../state/offers_cubit.dart';
|
||||||
|
|
@ -18,6 +19,7 @@ class MarketScreen extends StatefulWidget {
|
||||||
required this.social,
|
required this.social,
|
||||||
required this.settings,
|
required this.settings,
|
||||||
this.location,
|
this.location,
|
||||||
|
this.outbox,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -28,6 +30,10 @@ class MarketScreen extends StatefulWidget {
|
||||||
/// null (tests, or a platform without it) the shortcut is hidden.
|
/// null (tests, or a platform without it) the shortcut is hidden.
|
||||||
final CoarseLocationProvider? location;
|
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
|
@override
|
||||||
State<MarketScreen> createState() => _MarketScreenState();
|
State<MarketScreen> createState() => _MarketScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +49,10 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _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<VarietyRepository>() : null;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final cubit = await createOffersCubit(widget.social, widget.settings);
|
final cubit = await createOffersCubit(widget.social, widget.settings);
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
|
|
@ -56,7 +66,20 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
await previous?.close();
|
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<void> _openConfig() async {
|
Future<void> _openConfig() async {
|
||||||
|
|
@ -73,7 +96,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
/// coarse area. Prompts for setup when the area isn't set.
|
/// coarse area. Prompts for setup when the area isn't set.
|
||||||
Future<void> _shareMine() async {
|
Future<void> _shareMine() async {
|
||||||
final cubit = _cubit;
|
final cubit = _cubit;
|
||||||
if (cubit == null || !cubit.isOnline) return;
|
if (cubit == null) return;
|
||||||
final repo = context.read<VarietyRepository>();
|
final repo = context.read<VarietyRepository>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -89,12 +112,23 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare)));
|
messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare)));
|
||||||
return;
|
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(
|
final count = await cubit.publishLots(
|
||||||
lots,
|
lots,
|
||||||
authorPubkeyHex: widget.social.publicKeyHex,
|
authorPubkeyHex: widget.social.publicKeyHex,
|
||||||
areaGeohash: area,
|
areaGeohash: area,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
|
||||||
messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count))));
|
messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count))));
|
||||||
await cubit.discover(area); // refresh — now including the just-shared lots
|
await cubit.discover(area); // refresh — now including the just-shared lots
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +147,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(t.market.title),
|
title: Text(t.market.title),
|
||||||
actions: [
|
actions: [
|
||||||
if (cubit != null && cubit.isOnline)
|
if (cubit != null && (cubit.isOnline || widget.outbox != null))
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('market.shareMine'),
|
key: const Key('market.shareMine'),
|
||||||
icon: const Icon(Icons.ios_share_outlined),
|
icon: const Icon(Icons.ios_share_outlined),
|
||||||
|
|
|
||||||
31
apps/app_seeds/test/services/offer_outbox_test.dart
Normal file
31
apps/app_seeds/test/services/offer_outbox_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,11 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/data/variety_repository.dart';
|
import 'package:tane/data/variety_repository.dart';
|
||||||
import 'package:tane/db/enums.dart';
|
import 'package:tane/db/enums.dart';
|
||||||
import 'package:tane/services/offer_mapper.dart';
|
import 'package:tane/services/offer_mapper.dart';
|
||||||
|
import 'package:tane/services/offer_outbox.dart';
|
||||||
import 'package:tane/state/offers_cubit.dart';
|
import 'package:tane/state/offers_cubit.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
/// In-memory [OfferTransport] for tests: addressable by id, discover matches by
|
/// In-memory [OfferTransport] for tests: addressable by id, discover matches by
|
||||||
/// geohash prefix. No relay, no network.
|
/// geohash prefix. No relay, no network.
|
||||||
class FakeOfferTransport implements OfferTransport {
|
class FakeOfferTransport implements OfferTransport {
|
||||||
|
|
@ -196,6 +199,63 @@ void main() {
|
||||||
await noArea.close();
|
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 {
|
test('offline (no transport): degrades gracefully, never throws', () async {
|
||||||
final cubit = OffersCubit(null);
|
final cubit = OffersCubit(null);
|
||||||
expect(cubit.isOnline, isFalse);
|
expect(cubit.isOnline, isFalse);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue