Compare commits
No commits in common. "05e4689339029b62d3fbed7b5ce9e0dd9ce19341" and "8aa37bde3407a38f4d177ca561aae37da8d2baaa" have entirely different histories.
05e4689339
...
8aa37bde34
35 changed files with 278 additions and 702 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ class _Translations$market$es extends Translations$market$en {
|
|||
@override String get contact => 'Mensaje';
|
||||
@override String get mine => 'Tú';
|
||||
@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' => 'Tú',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue