Compare commits

..

No commits in common. "05e4689339029b62d3fbed7b5ce9e0dd9ce19341" and "8aa37bde3407a38f4d177ca561aae37da8d2baaa" have entirely different histories.

35 changed files with 278 additions and 702 deletions

View file

@ -516,7 +516,7 @@
"contact": "Mensaxe",
"mine": "Tu",
"configTitle": "Configuración de compartir",
"setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu'otres persones cercanes puedan atopalo.",
"setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu'otres persones atopen lo qu'ufiertes, ensin nenguna empresa en mediu.",
"areaLabel": "La to zona",
"areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.",
"areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu",

View file

@ -519,7 +519,7 @@
"contact": "Nachricht",
"mine": "Du",
"configTitle": "Teilen-Einrichtung",
"setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.",
"setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.",
"areaLabel": "Dein Gebiet",
"areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.",
"areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt",

View file

@ -541,7 +541,7 @@
"contact": "Message",
"mine": "You",
"configTitle": "Sharing setup",
"setupIntro": "Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.",
"setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.",
"areaLabel": "Your area",
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
"areaSet": "Your area is set — kept coarse, never your exact spot",

View file

@ -540,7 +540,7 @@
"contact": "Mensaje",
"mine": "Tú",
"configTitle": "Configuración de compartir",
"setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.",
"setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.",
"areaLabel": "Tu zona",
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
"areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto",

View file

@ -519,7 +519,7 @@
"contact": "Message",
"mine": "Vous",
"configTitle": "Configuration du partage",
"setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d'autres près de chez vous puissent le trouver.",
"setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.",
"areaLabel": "Votre zone",
"areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.",
"areaSet": "Votre zone est définie — approximative, jamais votre point exact",

View file

@ -541,7 +541,7 @@
"contact": "Mensagem",
"mine": "Tu",
"configTitle": "Configuração de partilha",
"setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.",
"setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.",
"areaLabel": "A tua zona",
"areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.",
"areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato",

View file

@ -541,7 +541,7 @@
"contact": "Mensagem",
"mine": "Você",
"configTitle": "Configuração de compartilha",
"setupIntro": "Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.",
"setupIntro": "Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.",
"areaLabel": "A sua zona",
"areaHelp": "Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.",
"areaSet": "A sua zona está definida — aproximada, nunca o seu ponto exato",

View file

@ -6,7 +6,7 @@
/// Locales: 8
/// Strings: 4287 (535 per locale)
///
/// Built on 2026-07-22 at 11:02 UTC
/// Built on 2026-07-20 at 21:06 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -766,7 +766,7 @@ class _Translations$market$ast extends Translations$market$en {
@override String get contact => 'Mensaxe';
@override String get mine => 'Tu';
@override String get configTitle => 'Configuración de compartir';
@override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.';
@override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.';
@override String get areaLabel => 'La to zona';
@override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.';
@override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu';
@ -1802,7 +1802,7 @@ extension on TranslationsAst {
'market.contact' => 'Mensaxe',
'market.mine' => 'Tu',
'market.configTitle' => 'Configuración de compartir',
'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.',
'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.',
'market.areaLabel' => 'La to zona',
'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.',
'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu',

View file

@ -769,7 +769,7 @@ class _Translations$market$de extends Translations$market$en {
@override String get contact => 'Nachricht';
@override String get mine => 'Du';
@override String get configTitle => 'Teilen-Einrichtung';
@override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.';
@override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.';
@override String get areaLabel => 'Dein Gebiet';
@override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.';
@override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt';
@ -1801,7 +1801,7 @@ extension on TranslationsDe {
'market.contact' => 'Nachricht',
'market.mine' => 'Du',
'market.configTitle' => 'Teilen-Einrichtung',
'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.',
'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.',
'market.areaLabel' => 'Dein Gebiet',
'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.',
'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt',

View file

@ -1483,8 +1483,8 @@ class Translations$market$en {
/// en: 'Sharing setup'
String get configTitle => 'Sharing setup';
/// en: 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.'
String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.';
/// en: 'Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.'
String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.';
/// en: 'Your area'
String get areaLabel => 'Your area';
@ -3186,7 +3186,7 @@ extension on Translations {
'market.contact' => 'Message',
'market.mine' => 'You',
'market.configTitle' => 'Sharing setup',
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.',
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.',
'market.areaLabel' => 'Your area',
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
'market.areaSet' => 'Your area is set — kept coarse, never your exact spot',

View file

@ -807,7 +807,7 @@ class _Translations$market$es extends Translations$market$en {
@override String get contact => 'Mensaje';
@override String get mine => '';
@override String get configTitle => 'Configuración de compartir';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.';
@override String get areaLabel => 'Tu zona';
@override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.';
@override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto';
@ -1924,7 +1924,7 @@ extension on TranslationsEs {
'market.contact' => 'Mensaje',
'market.mine' => '',
'market.configTitle' => 'Configuración de compartir',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.',
'market.areaLabel' => 'Tu zona',
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto',

View file

@ -769,7 +769,7 @@ class _Translations$market$fr extends Translations$market$en {
@override String get contact => 'Message';
@override String get mine => 'Vous';
@override String get configTitle => 'Configuration du partage';
@override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.';
@override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.';
@override String get areaLabel => 'Votre zone';
@override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.';
@override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact';
@ -1801,7 +1801,7 @@ extension on TranslationsFr {
'market.contact' => 'Message',
'market.mine' => 'Vous',
'market.configTitle' => 'Configuration du partage',
'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.',
'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.',
'market.areaLabel' => 'Votre zone',
'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.',
'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact',

View file

@ -808,7 +808,7 @@ class Translations$market$pt extends Translations$market$en {
@override String get contact => 'Mensagem';
@override String get mine => 'Tu';
@override String get configTitle => 'Configuração de partilha';
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.';
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.';
@override String get areaLabel => 'A tua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.';
@override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato';
@ -1926,7 +1926,7 @@ extension on TranslationsPt {
'market.contact' => 'Mensagem',
'market.mine' => 'Tu',
'market.configTitle' => 'Configuração de partilha',
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.',
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.',
'market.areaLabel' => 'A tua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.',
'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato',

View file

@ -809,7 +809,7 @@ class _Translations$market$pt_BR extends Translations$market$pt {
@override String get contact => 'Mensagem';
@override String get mine => 'Você';
@override String get configTitle => 'Configuração de compartilha';
@override String get setupIntro => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.';
@override String get setupIntro => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.';
@override String get areaLabel => 'A sua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.';
@override String get areaSet => 'A sua zona está definida — aproximada, nunca o seu ponto exato';
@ -1927,7 +1927,7 @@ extension on TranslationsPtBr {
'market.contact' => 'Mensagem',
'market.mine' => 'Você',
'market.configTitle' => 'Configuração de compartilha',
'market.setupIntro' => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.',
'market.setupIntro' => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.',
'market.areaLabel' => 'A sua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.',
'market.areaSet' => 'A sua zona está definida — aproximada, nunca o seu ponto exato',

View file

@ -25,36 +25,19 @@ class SocialConnection {
required SocialSettings settings,
SessionOpener? open,
Stream<bool>? online,
List<Duration>? retrySchedule,
}) : _settings = settings,
_open = open ?? social.openSession,
_online = online,
_retrySchedule = retrySchedule ?? _defaultRetrySchedule;
/// Backoff for self-retries after a failed attempt while started and not
/// knowingly offline (the fresh-install case: online the whole time, first
/// connect fails, so no connectivity change ever retriggers a connect).
static const _defaultRetrySchedule = [
Duration(seconds: 5),
Duration(seconds: 15),
Duration(seconds: 45),
Duration(seconds: 90),
];
_online = online;
final SocialSettings _settings;
final SessionOpener _open;
final Stream<bool>? _online;
final List<Duration> _retrySchedule;
final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current;
Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub;
bool _disposed = false;
bool _started = false;
bool _knownOffline = false;
Timer? _retryTimer;
int _retryIndex = 0;
/// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> get sessions => _sessions.stream;
@ -65,14 +48,10 @@ class SocialConnection {
/// Begins watching connectivity (reconnect on regain, drop when offline) and
/// attempts an initial connect. Idempotent-ish; call once at startup.
void start() {
_started = true;
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
_knownOffline = !isOnline;
if (!isOnline) {
_cancelRetry();
_drop();
} else if (_current == null) {
_retryIndex = 0; // fresh network start the backoff over
unawaited(session());
}
});
@ -96,40 +75,15 @@ class SocialConnection {
return null;
}
_current = s;
_retryIndex = 0;
_cancelRetry();
_sessions.add(s);
return s;
} catch (_) {
_scheduleRetry(); // unreachable retry with backoff (see below)
return null;
return null; // unreachable a later connectivity change retries
} finally {
_pending = null;
}
}
/// After a failed attempt, retries by itself while started and not knowingly
/// offline a connectivity change may never come if the device was online
/// all along. Un-started connections (one-shot [session] callers, tests)
/// never leave a timer behind.
void _scheduleRetry() {
if (!_started || _disposed || _knownOffline || _current != null) return;
_cancelRetry();
final i = _retryIndex < _retrySchedule.length
? _retryIndex
: _retrySchedule.length - 1;
_retryIndex++;
_retryTimer = Timer(_retrySchedule[i], () {
if (_disposed || _current != null) return;
unawaited(session());
});
}
void _cancelRetry() {
_retryTimer?.cancel();
_retryTimer = null;
}
void _drop() {
final s = _current;
_current = null;
@ -141,7 +95,6 @@ class SocialConnection {
Future<void> dispose() async {
_disposed = true;
_cancelRetry();
await _onlineSub?.cancel();
_onlineSub = null;
_drop();

View file

@ -10,7 +10,6 @@ import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
/// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle.
@ -29,7 +28,6 @@ class OffersState extends Equatable {
this.nextPageCursor,
this.publishing = false,
this.hasSearched = false,
this.connectionEpoch = 0,
this.error,
});
@ -82,11 +80,6 @@ class OffersState extends Equatable {
/// from "searched, found nothing".
final bool hasSearched;
/// Bumped whenever the underlying transport comes or goes, so the UI rebuilds
/// and re-reads [OffersCubit.isOnline] states differing only here are
/// otherwise Equatable-equal and bloc would skip the emit.
final int connectionEpoch;
/// Last error, in human terms for the UI (null when fine).
final String? error;
@ -143,7 +136,6 @@ class OffersState extends Equatable {
int? Function()? nextPageCursor,
bool? publishing,
bool? hasSearched,
int? connectionEpoch,
String? Function()? error,
}) {
return OffersState(
@ -161,7 +153,6 @@ class OffersState extends Equatable {
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
publishing: publishing ?? this.publishing,
hasSearched: hasSearched ?? this.hasSearched,
connectionEpoch: connectionEpoch ?? this.connectionEpoch,
error: error != null ? error() : this.error,
);
}
@ -181,7 +172,6 @@ class OffersState extends Equatable {
nextPageCursor,
publishing,
hasSearched,
connectionEpoch,
error,
];
}
@ -193,40 +183,15 @@ class OffersState extends Equatable {
class OffersCubit extends Cubit<OffersState> {
OffersCubit(
this._transport, {
SocialConnection? connection,
Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose,
}) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail,
_onDispose = onDispose,
super(const OffersState()) {
// Fresh-install fix: the transport captured at build time may be null while
// the shared connection is still coming up (or dropped). Follow the
// connection so the market recovers by itself instead of staying offline
// until a manual retry.
_connSub = connection?.sessions.listen(_onSession);
}
super(const OffersState());
OfferTransport? _transport;
StreamSubscription<SocialSession?>? _connSub;
/// The last area prefix asked of [discover]; re-run when the connection
/// (re)appears so results show without the user tapping anything.
String? _lastPrefix;
void _onSession(SocialSession? session) {
if (isClosed) return;
final transport = session?.offers;
if (identical(transport, _transport)) return;
_transport = transport;
final prefix = _lastPrefix;
if (transport != null && prefix != null) {
unawaited(discover(prefix)); // emits fresh states as results arrive
} else {
emit(state.copyWith(connectionEpoch: state.connectionEpoch + 1));
}
}
final OfferTransport? _transport;
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired.
@ -260,7 +225,6 @@ class OffersCubit extends Cubit<OffersState> {
/// from now on older results arrive via [loadNextPage], not by draining the
/// whole area into memory.
Future<void> discover(String geohashPrefix) async {
_lastPrefix = geohashPrefix;
final transport = _transport;
if (transport == null) {
emit(state.copyWith(error: () => 'offline', hasSearched: true));
@ -503,7 +467,6 @@ class OffersCubit extends Cubit<OffersState> {
@override
Future<void> close() async {
_searchTimeout?.cancel();
await _connSub?.cancel();
await _sub?.cancel();
await _onDispose?.call();
return super.close();
@ -521,7 +484,6 @@ Future<OffersCubit> createOffersCubit(
final session = await connection.session();
return OffersCubit(
session?.offers,
connection: connection, // keeps following (re)connects see _onSession
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
);

View file

@ -100,11 +100,11 @@ 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;
final repo =
widget.outbox != null ? context.read<VarietyRepository>() : null;
setState(() => _loading = true);
final cubit = await createOffersCubit(widget.connection, repository: repo);
final cubit =
await createOffersCubit(widget.connection, repository: repo);
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
final area = await widget.settings.areaGeohash();
@ -122,10 +122,10 @@ class _MarketScreenState extends State<MarketScreen> {
_loading = false;
});
await previous?.close();
if (area.isNotEmpty) {
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 && cubit.isOnline) {
if (outbox != null && repo != null) {
await flushOutbox(
outbox: outbox,
cubit: cubit,
@ -136,8 +136,6 @@ class _MarketScreenState extends State<MarketScreen> {
}
if (mounted) {
final precision = await widget.settings.searchPrecision();
// Even when offline: this records the wanted area on the cubit, which
// re-runs the discovery by itself once the connection comes up.
if (mounted) await cubit.discover(searchPrefix(area, precision));
}
}
@ -208,18 +206,12 @@ class _MarketScreenState extends State<MarketScreen> {
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
// count == 0 with lots to share means every relay refused say so plainly
// rather than "shared 0", which reads like success.
messenger.showSnackBar(
SnackBar(
content: Text(
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
),
),
);
messenger.showSnackBar(SnackBar(
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)),
));
final precision = await widget.settings.searchPrecision();
if (!mounted) return;
await cubit.discover(
searchPrefix(area, precision),
); // refresh incl. just-shared
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared
}
@override
@ -248,13 +240,13 @@ class _MarketScreenState extends State<MarketScreen> {
),
floatingActionButton:
cubit != null && (cubit.isOnline || widget.outbox != null)
? FloatingActionButton.extended(
key: const Key('market.shareMine'),
onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine),
)
: null,
? FloatingActionButton.extended(
key: const Key('market.shareMine'),
onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine),
)
: null,
body: _loading || cubit == null
? const Center(child: CircularProgressIndicator())
: BlocProvider.value(
@ -306,17 +298,6 @@ class MarketBody extends StatelessWidget {
final t = context.t;
return BlocBuilder<OffersCubit, OffersState>(
builder: (context, state) {
// A new user's first step is setting a zone — which works offline — so
// that prompt wins over the connection error.
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (!context.read<OffersCubit>().isOnline) {
// Default community servers exist, so "offline" means unreachable
// offer a retry rather than "set up sharing".
@ -328,6 +309,15 @@ class MarketBody extends StatelessWidget {
onAction: onRetry,
);
}
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (state.searching && state.offers.isEmpty) {
return Center(
child: Column(
@ -335,10 +325,8 @@ class MarketBody extends StatelessWidget {
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
t.market.searching,
style: const TextStyle(color: seedMuted),
),
Text(t.market.searching,
style: const TextStyle(color: seedMuted)),
],
),
);
@ -382,22 +370,16 @@ class MarketBody extends StatelessWidget {
prefixIcon: const Icon(Icons.search, color: seedMuted),
// Offer to save the current search once it's worth alerting on
// (some text typed or a chip active), never for the bare list.
suffixIcon:
savedSearches != null &&
suffixIcon: savedSearches != null &&
(state.query.trim().isNotEmpty ||
state.hasActiveFilter)
? IconButton(
key: const Key('market.saveSearch'),
icon: const Icon(
Icons.bookmark_add_outlined,
color: seedGreen,
),
icon: const Icon(Icons.bookmark_add_outlined,
color: seedGreen),
tooltip: t.savedSearches.save,
onPressed: () => _saveCurrentSearch(
context,
savedSearches!,
state,
),
onPressed: () =>
_saveCurrentSearch(context, savedSearches!, state),
)
: null,
hintStyle: const TextStyle(color: seedMuted),
@ -449,17 +431,13 @@ class MarketBody extends StatelessWidget {
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),
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(),
),
child: Center(child: CircularProgressIndicator()),
);
}
final o = visible[i];
@ -596,9 +574,7 @@ class _OfferCard extends StatelessWidget {
if (mine) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen,
borderRadius: BorderRadius.circular(20),
@ -622,10 +598,8 @@ class _OfferCard extends StatelessWidget {
children: [
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4),
Text(
t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
Text(t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13)),
if (offer.isOrganic) ...[
const SizedBox(width: 10),
const Icon(Icons.eco, size: 15, color: seedGreen),
@ -633,8 +607,7 @@ class _OfferCard extends StatelessWidget {
const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null)
Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'
.trim(),
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
style: const TextStyle(
color: seedOnSurface,
fontWeight: FontWeight.w600,
@ -933,226 +906,194 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Widget build(BuildContext context) {
final t = context.t;
final hasArea = _area.text.trim().isNotEmpty;
// SafeArea keeps the Save button above the system nav bar on edge-to-edge
// devices; the viewInsets padding still lifts it above the keyboard.
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: _loading
? const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.market.configTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: seedTitle,
),
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: _loading
? const SizedBox(
height: 120, child: Center(child: CircularProgressIndicator()))
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.market.configTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: seedTitle,
),
const SizedBox(height: 8),
Text(
t.market.setupIntro,
style: const TextStyle(
color: seedMuted,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
t.market.setupIntro,
style: const TextStyle(
color: seedMuted, fontSize: 13, height: 1.4),
),
const SizedBox(height: 18),
// Setting your area from device location is the human path;
// typing a code is the advanced fallback.
if (widget.location != null)
FilledButton.tonalIcon(
key: const Key('market.useLocation'),
onPressed: _locationBusy ? null : _useLocation,
icon: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
const SizedBox(height: 18),
// Setting your area from device location is the human path;
// typing a code is the advanced fallback.
if (widget.location != null)
FilledButton.tonalIcon(
key: const Key('market.useLocation'),
onPressed: _locationBusy ? null : _useLocation,
icon: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
_locationError!,
style: const TextStyle(
color: Color(0xFFB3261E),
fontSize: 12,
),
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
hasArea ? Icons.check_circle : Icons.pending_outlined,
size: 18,
color: hasArea ? seedGreen : seedMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasArea ? t.market.areaSet : t.market.areaNotSet,
style: TextStyle(
color: hasArea ? seedOnSurface : seedMuted,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
t.market.rangeLabel,
_locationError!,
style: const TextStyle(
color: Color(0xFFB3261E), fontSize: 12),
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
hasArea
? Icons.check_circle
: Icons.pending_outlined,
size: 18,
color: hasArea ? seedGreen : seedMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasArea ? t.market.areaSet : t.market.areaNotSet,
style: TextStyle(
color: hasArea ? seedOnSurface : seedMuted,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.rangeLabel,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(value: 5, label: Text(t.market.rangeNear)),
ButtonSegment(value: 4, label: Text(t.market.rangeArea)),
ButtonSegment(value: 3, label: Text(t.market.rangeRegion)),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
const SizedBox(height: 8),
Theme(
data: Theme.of(context)
.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: const Key('market.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
title: Text(
t.market.advanced,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(
value: 5,
label: Text(t.market.rangeNear),
children: [
TextField(
key: const Key('market.area'),
controller: _area,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
),
ButtonSegment(
value: 4,
label: Text(t.market.rangeArea),
const SizedBox(height: 14),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13, color: seedMuted),
),
),
ButtonSegment(
value: 3,
label: Text(t.market.rangeRegion),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2, bottom: 4),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12, color: seedMuted, height: 1.3),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
),
),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
const SizedBox(height: 8),
Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: const Key('market.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
title: Text(
t.market.advanced,
style: const TextStyle(
fontSize: 13,
color: seedMuted,
),
),
children: [
TextField(
key: const Key('market.area'),
controller: _area,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
),
const SizedBox(height: 14),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13,
color: seedMuted,
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2,
bottom: 4,
),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12,
color: seedMuted,
height: 1.3,
),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
),
),
);
}
}

View file

@ -1,7 +1,7 @@
name: tane
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
publish_to: 'none'
version: 0.1.12+14
version: 0.1.10+12
environment:
sdk: ^3.11.5

View file

@ -45,13 +45,11 @@ void main() {
required List<FakeChannel> opened,
Stream<bool>? online,
bool Function()? fail,
List<Duration>? retrySchedule,
}) =>
SocialConnection(
social: social,
settings: settings,
online: online,
retrySchedule: retrySchedule,
open: (_) async {
if (fail?.call() ?? false) throw StateError('unreachable');
final ch = FakeChannel();
@ -108,69 +106,6 @@ void main() {
await online.close();
});
test(
'started connection retries by itself after a failed first attempt, '
'without any connectivity event', () async {
// The fresh-install case: device online the whole time, but the very first
// connect fails (cold DNS/TLS). The connectivity stream never fires, so
// only the backoff retry can bring the market up without a manual Retry.
final opened = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(), // connectivity never speaks
fail: () => down,
retrySchedule: const [Duration(milliseconds: 5)],
);
final emitted = <SocialSession?>[];
conn.sessions.listen(emitted.add);
conn.start();
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNull); // first attempt failed
down = false; // network path recovers
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(conn.current, isNotNull, reason: 'backoff retry reconnected');
expect(opened, hasLength(1));
expect(emitted.last, isNotNull, reason: 'recovery announced on sessions');
await conn.dispose();
});
test('a plain session() failure schedules no retry when never started',
() async {
// Widget tests build cubits against an un-started connection; a failed
// one-shot session() must not leave a pending retry timer behind.
final opened = <FakeChannel>[];
final conn = make(
opened: opened,
fail: () => true,
retrySchedule: const [Duration(milliseconds: 5)],
);
expect(await conn.session(), isNull);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(opened, isEmpty); // no background retry fired
await conn.dispose();
});
test('dispose cancels a pending backoff retry', () async {
final opened = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(),
fail: () => down,
retrySchedule: const [Duration(milliseconds: 20)],
);
conn.start();
await Future<void>.delayed(Duration.zero); // first attempt fails
await conn.dispose(); // cancels the scheduled retry
down = false;
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(opened, isEmpty, reason: 'no reconnect after dispose');
});
test('returns null when the relay is unreachable, and retries later',
() async {
final opened = <FakeChannel>[];

View file

@ -7,11 +7,7 @@ import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/services/discovery_area.dart';
import 'package:tane/services/offer_mapper.dart';
import 'package:nostr/nostr.dart';
import 'package:tane/services/offer_outbox.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/state/offers_cubit.dart';
import '../support/test_support.dart';
@ -839,111 +835,4 @@ void main() {
await cubit.close();
});
});
group('connection recovery (fresh-install fix)', () {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
late SocialService social;
late SocialSettings settings;
setUp(() async {
social = await SocialService.fromRootSeedHex(seedHex);
settings = SocialSettings(InMemorySecretStore());
});
SocialConnection makeConnection({
required bool Function() down,
Stream<bool>? online,
}) =>
SocialConnection(
social: social,
settings: settings,
online: online ?? const Stream.empty(),
retrySchedule: const [Duration(milliseconds: 5)],
open: (_) async {
if (down()) throw StateError('unreachable');
return SocialSession(_NoopChannel());
},
);
test('comes online by itself when the shared connection connects later',
() async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isFalse); // built while unreachable
final epochBefore = cubit.state.connectionEpoch;
conn.start();
down = false; // network path recovers; backoff retry reconnects
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed');
expect(cubit.state.connectionEpoch, greaterThan(epochBefore),
reason: 'a state was emitted so the UI re-reads isOnline');
await cubit.close();
await conn.dispose();
});
test('re-runs the last discovery when the connection recovers', () async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
await cubit.discover('sp3e9'); // offline error, but the wish is kept
expect(cubit.state.error, 'offline');
conn.start();
down = false;
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue);
expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran');
expect(cubit.state.hasSearched, isTrue);
expect(cubit.state.error, isNull, reason: 'offline error cleared');
await cubit.close();
await conn.dispose();
});
test('goes offline (and tells the UI) when the session drops', () async {
final online = StreamController<bool>.broadcast();
final conn = makeConnection(down: () => false, online: online.stream);
conn.start();
await pumpEventQueue();
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isTrue);
final epochBefore = cubit.state.connectionEpoch;
online.add(false); // network lost
await pumpEventQueue();
expect(cubit.isOnline, isFalse);
expect(cubit.state.connectionEpoch, greaterThan(epochBefore));
await cubit.close();
await conn.dispose();
await online.close();
});
});
}
/// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer
/// transport answers with empty results.
class _NoopChannel implements NostrChannel {
@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<List<Event>> reqOnce(Filter filter) async => const [];
@override
Future<void> close() async {}
}

View file

@ -84,9 +84,8 @@ void main() {
findsOneWidget);
});
testWidgets(
'a fresh install (no area, unreachable) asks for the area first, '
'not the connection', (tester) async {
testWidgets('MarketScreen with no relays configured degrades to offline',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
@ -95,23 +94,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
// Setting a zone works offline and is the first step the connection
// error must not bury it.
expect(find.text('Set your area'), findsOneWidget);
expect(find.text('Retry'), findsNothing);
});
testWidgets('with an area set, unreachable servers degrade to retry',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await settings.setAreaGeohash('ezsn9');
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
expect(find.text('Retry'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry
});
testWidgets('the range selector persists the chosen search precision',
@ -165,36 +148,6 @@ void main() {
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
});
testWidgets('the config sheet Save button stays above the system nav bar',
(tester) async {
// Edge-to-edge Android: a 50-logical-px gesture/nav bar at the bottom.
tester.view.padding = const FakeViewPadding(bottom: 150); // physical px
tester.view.viewPadding = const FakeViewPadding(bottom: 150);
addTearDown(tester.view.reset);
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('market.config')));
await tester.pumpAndSettle();
final save = find.byKey(const Key('market.save'));
await tester.ensureVisible(save);
await tester.pumpAndSettle();
final screenHeight = tester.view.physicalSize.height /
tester.view.devicePixelRatio; // 600 on the default test surface
const navBarLogical = 150 / 3.0; // FakeViewPadding is physical px
expect(
tester.getRect(save).bottom,
lessThanOrEqualTo(screenHeight - navBarLogical + 0.1),
reason: 'Save must not sit under the system navigation bar',
);
});
testWidgets('"use my location" fills the area with a coarse geohash',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);

View file

@ -39,10 +39,9 @@ com cadernos e folhas de cálculo.
- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma
chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade.
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão
(é isso que significa ser **descentralizado**).
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim,
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser
**descentralizado**).
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem
teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras.

View file

@ -41,10 +41,9 @@ con libretas y hojas de cálculo.
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
Sin publicidad.
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
en mano (eso es que sea **descentralizada**).
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea
**descentralizada**).
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin
tener que dar tu nombre real ni tu teléfono si no quieres.

View file

@ -39,10 +39,9 @@ still do it with notebooks and spreadsheets.
- **Your data is yours.** Your information is kept on **your own phone**, protected with a
key, not on some company's computers. We don't sell it or upload it anywhere. No ads.
- **No one can shut it down or control it.** There's no central computer that everything
passes through: what you share travels over **community servers**, run by people and
collectives — there are many, and anyone can add their own — so none of them is a center
that could censor it or charge you to use it, like passing seeds from hand to hand
(that's what being **decentralized** means).
passes through: people communicate **directly with each other**, like passing seeds from
hand to hand. So no one can censor it or charge you to use it (that's what being
**decentralized** means).
- **You trust by word of mouth.** You deal with people you know and with those vouched for
by people you trust — the way it's always worked in a village or at a fair — without
having to give your real name or phone number unless you want to.

View file

@ -46,10 +46,9 @@ class NostrOfferTransport implements OfferTransport {
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
// Newest first; the relays dedup within themselves and reqOnce dedups
// across them, but order is not guaranteed, so sort here on a copy, as
// the channel may hand out an unmodifiable list.
final events = [...await _conn.reqOnce(_filter(query))]
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) {

View file

@ -26,32 +26,19 @@ class RelayPool implements NostrChannel {
/// Number of relays currently connected (for diagnostics/tests).
int get relayCount => _connections.length;
/// Connects to each of [relayUrls], skipping any that fail or take longer
/// than [connectTimeout] a relay on a silently-filtering network would
/// otherwise hang the whole pool for minutes. Throws [StateError] only when
/// NONE are reachable, so the caller can treat that as "offline".
/// Connects to each of [relayUrls], skipping any that fail. Throws
/// [StateError] only when NONE are reachable, so the caller can treat that as
/// "offline".
static Future<RelayPool> connect(
List<String> relayUrls, {
required NostrIdentity identity,
Duration connectTimeout = const Duration(seconds: 10),
}) async {
final connections = <NostrConnection>[];
await Future.wait(
relayUrls.map((url) async {
try {
final attempt = NostrConnection.connect(url, identity: identity);
connections.add(
await attempt.timeout(
connectTimeout,
onTimeout: () {
// Abandon the hung attempt; if it ever completes, close it so
// the socket doesn't leak.
unawaited(
attempt.then((c) => c.close()).catchError((_) {}),
);
throw TimeoutException('relay connect timed out', connectTimeout);
},
),
await NostrConnection.connect(url, identity: identity),
);
} catch (_) {
// Unreachable relay skip it, keep the rest.

View file

@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
@ -59,38 +58,6 @@ void main() {
await r1.stop();
});
test('a hung relay cannot stall the pool past the connect timeout', () async {
// A server that accepts TCP but never answers the WebSocket handshake
// the shape of a silently-filtered network, where connect hangs for minutes.
final hang = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final held = <Socket>[];
hang.listen(held.add);
final r1 = await MiniRelay.start();
final alice = await idFor(5);
final sw = Stopwatch()..start();
final pool = await RelayPool.connect(
[r1.url, 'ws://127.0.0.1:${hang.port}'],
identity: alice,
connectTimeout: const Duration(milliseconds: 500),
);
sw.stop();
expect(pool.relayCount, 1, reason: 'the live relay is kept');
expect(
sw.elapsed,
lessThan(const Duration(seconds: 5)),
reason: 'the hung relay must not stall the whole pool',
);
await pool.close();
for (final s in held) {
s.destroy();
}
await hang.close();
await r1.stop();
});
test('throws when no relay is reachable (caller treats as offline)', () async {
final alice = await idFor(4);
expect(

View file

@ -121,15 +121,11 @@ a { color: var(--green); }
.lang-menu a.lang:hover { background: var(--container); }
.lang-menu span.current { color: var(--muted); font-weight: 600; }
/* Narrow phones: shrink a bit, but only wrap the nav below the brand if it
actually stops fitting the language menu is a compact pill now, not
three spelled-out language names, so it fits alongside "About Legal" down
to fairly small widths. flex-wrap on .site-header handles the overflow
case on its own; no need to force it. */
/* Narrow phones: brand on its own row, nav wraps beneath it, left-aligned. */
@media (max-width: 560px) {
.site-header { gap: .5rem; }
.brand { font-size: 1.15rem; }
.site-nav { gap: .4rem 1rem; font-size: .95rem; }
.site-nav { width: 100%; gap: .4rem 1rem; font-size: .95rem; }
}
main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); }

View file

@ -57,7 +57,7 @@ values:
points:
- "No center: no company can shut it down, censor it, or be fined for it."
- "Anti-monopoly by design — sharing seeds multiplies the commons instead of depleting it."
- "Spreads like a seed, person to person; what you share travels through community servers anyone can run — not a company's central server."
- "Spreads like a seed, person to person, with no server in the middle."
- "Free software (AGPL-3.0). No ads, no commissions, no business model."
---

View file

@ -57,7 +57,7 @@ values:
points:
- "Sin centro: ninguna empresa puede apagarlo, censurarlo ni ser multada por ello."
- "Anti-monopolio por diseño: compartir semillas multiplica el común en lugar de agotarlo."
- "Se propaga como una semilla, de persona a persona; lo que compartes viaja por servidores comunitarios que cualquiera puede montar — no por el servidor central de una empresa."
- "Se propaga como una semilla, de persona a persona, sin servidor en medio."
- "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio."
---

View file

@ -57,7 +57,7 @@ values:
points:
- "Sem centro: nenhuma empresa o pode desligar, censurar ou multar por isso."
- "Anti-monopólio por desenho — partilhar sementes multiplica os comuns em vez de os esgotar."
- "Espalha-se como uma semente, de pessoa a pessoa; o que partilhas viaja por servidores comunitários que qualquer pessoa pode montar — não pelo servidor central de uma empresa."
- "Espalha-se como uma semente, de pessoa a pessoa, sem servidor pelo meio."
- "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio."
---

View file

@ -45,10 +45,9 @@ still do it with notebooks and spreadsheets.
- **Your data is yours.** Your information is kept on **your own phone**, protected with a
key, not on some company's computers. We don't sell it or upload it anywhere. No ads.
- **No one can shut it down or control it.** There's no central computer that everything
passes through: what you share travels over **community servers**, run by people and
collectives — there are many, and anyone can add their own — so none of them is a center
that could censor it or charge you to use it, like passing seeds from hand to hand
(that's what being **decentralized** means).
passes through: people communicate **directly with each other**, like passing seeds from
hand to hand. So no one can censor it or charge you to use it (that's what being
**decentralized** means).
- **You trust by word of mouth.** You deal with people you know and with those vouched for
by people you trust — the way it's always worked in a village or at a fair — without
having to give your real name or phone number unless you want to.

View file

@ -47,10 +47,9 @@ con libretas y hojas de cálculo.
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
Sin publicidad.
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
en mano (eso es que sea **descentralizada**).
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea
**descentralizada**).
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin
tener que dar tu nombre real ni tu teléfono si no quieres.

View file

@ -45,10 +45,9 @@ com cadernos e folhas de cálculo.
- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma
chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade.
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão
(é isso que significa ser **descentralizado**).
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim,
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser
**descentralizado**).
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem
teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras.