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:
vjrj 2026-07-10 10:45:53 +02:00
parent 2a6b4b0947
commit cf5d0243ee
15 changed files with 222 additions and 8 deletions

View file

@ -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<MarketScreen> createState() => _MarketScreenState();
}
@ -43,6 +49,10 @@ class _MarketScreenState extends State<MarketScreen> {
}
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);
final cubit = await createOffersCubit(widget.social, widget.settings);
final area = await widget.settings.areaGeohash();
@ -56,7 +66,20 @@ class _MarketScreenState extends State<MarketScreen> {
_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<void> _openConfig() async {
@ -73,7 +96,7 @@ class _MarketScreenState extends State<MarketScreen> {
/// coarse area. Prompts for setup when the area isn't set.
Future<void> _shareMine() async {
final cubit = _cubit;
if (cubit == null || !cubit.isOnline) return;
if (cubit == null) return;
final repo = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
@ -89,12 +112,23 @@ class _MarketScreenState extends State<MarketScreen> {
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<MarketScreen> {
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),