Compare commits
8 commits
8aa37bde34
...
05e4689339
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e4689339 | |||
| 2c04f4bfb6 | |||
| 1a946d5c26 | |||
| 13b6b4bfce | |||
| 8a7dc8d3dc | |||
| 981cf10048 | |||
| 3ec6ed2329 | |||
| 4963282620 |
35 changed files with 698 additions and 274 deletions
|
|
@ -516,7 +516,7 @@
|
||||||
"contact": "Mensaxe",
|
"contact": "Mensaxe",
|
||||||
"mine": "Tu",
|
"mine": "Tu",
|
||||||
"configTitle": "Configuración de compartir",
|
"configTitle": "Configuración de compartir",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "La to zona",
|
"areaLabel": "La to zona",
|
||||||
"areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.",
|
"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",
|
"areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu",
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@
|
||||||
"contact": "Nachricht",
|
"contact": "Nachricht",
|
||||||
"mine": "Du",
|
"mine": "Du",
|
||||||
"configTitle": "Teilen-Einrichtung",
|
"configTitle": "Teilen-Einrichtung",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "Dein Gebiet",
|
"areaLabel": "Dein Gebiet",
|
||||||
"areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.",
|
"areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.",
|
||||||
"areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt",
|
"areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt",
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@
|
||||||
"contact": "Message",
|
"contact": "Message",
|
||||||
"mine": "You",
|
"mine": "You",
|
||||||
"configTitle": "Sharing setup",
|
"configTitle": "Sharing setup",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "Your area",
|
"areaLabel": "Your area",
|
||||||
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
|
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
|
||||||
"areaSet": "Your area is set — kept coarse, never your exact spot",
|
"areaSet": "Your area is set — kept coarse, never your exact spot",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@
|
||||||
"contact": "Mensaje",
|
"contact": "Mensaje",
|
||||||
"mine": "Tú",
|
"mine": "Tú",
|
||||||
"configTitle": "Configuración de compartir",
|
"configTitle": "Configuración de compartir",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "Tu zona",
|
"areaLabel": "Tu zona",
|
||||||
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
|
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
|
||||||
"areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto",
|
"areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto",
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@
|
||||||
"contact": "Message",
|
"contact": "Message",
|
||||||
"mine": "Vous",
|
"mine": "Vous",
|
||||||
"configTitle": "Configuration du partage",
|
"configTitle": "Configuration du partage",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "Votre zone",
|
"areaLabel": "Votre zone",
|
||||||
"areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.",
|
"areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.",
|
||||||
"areaSet": "Votre zone est définie — approximative, jamais votre point exact",
|
"areaSet": "Votre zone est définie — approximative, jamais votre point exact",
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@
|
||||||
"contact": "Mensagem",
|
"contact": "Mensagem",
|
||||||
"mine": "Tu",
|
"mine": "Tu",
|
||||||
"configTitle": "Configuração de partilha",
|
"configTitle": "Configuração de partilha",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "A tua zona",
|
"areaLabel": "A tua zona",
|
||||||
"areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.",
|
"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",
|
"areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato",
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@
|
||||||
"contact": "Mensagem",
|
"contact": "Mensagem",
|
||||||
"mine": "Você",
|
"mine": "Você",
|
||||||
"configTitle": "Configuração de compartilha",
|
"configTitle": "Configuração de compartilha",
|
||||||
"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.",
|
"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.",
|
||||||
"areaLabel": "A sua zona",
|
"areaLabel": "A sua zona",
|
||||||
"areaHelp": "Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.",
|
"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",
|
"areaSet": "A sua zona está definida — aproximada, nunca o seu ponto exato",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
/// Locales: 8
|
/// Locales: 8
|
||||||
/// Strings: 4287 (535 per locale)
|
/// Strings: 4287 (535 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-20 at 21:06 UTC
|
/// Built on 2026-07-22 at 11:02 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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 contact => 'Mensaxe';
|
||||||
@override String get mine => 'Tu';
|
@override String get mine => 'Tu';
|
||||||
@override String get configTitle => 'Configuración de compartir';
|
@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 — 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 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 areaLabel => 'La to zona';
|
@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 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';
|
@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.contact' => 'Mensaxe',
|
||||||
'market.mine' => 'Tu',
|
'market.mine' => 'Tu',
|
||||||
'market.configTitle' => 'Configuración de compartir',
|
'market.configTitle' => 'Configuración de compartir',
|
||||||
'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.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.areaLabel' => 'La to zona',
|
'market.areaLabel' => 'La to zona',
|
||||||
'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.',
|
'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',
|
'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 contact => 'Nachricht';
|
||||||
@override String get mine => 'Du';
|
@override String get mine => 'Du';
|
||||||
@override String get configTitle => 'Teilen-Einrichtung';
|
@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 - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.';
|
@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 areaLabel => 'Dein Gebiet';
|
@override String get areaLabel => 'Dein Gebiet';
|
||||||
@override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.';
|
@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';
|
@override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt';
|
||||||
|
|
@ -1801,7 +1801,7 @@ extension on TranslationsDe {
|
||||||
'market.contact' => 'Nachricht',
|
'market.contact' => 'Nachricht',
|
||||||
'market.mine' => 'Du',
|
'market.mine' => 'Du',
|
||||||
'market.configTitle' => 'Teilen-Einrichtung',
|
'market.configTitle' => 'Teilen-Einrichtung',
|
||||||
'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.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.areaLabel' => 'Dein Gebiet',
|
'market.areaLabel' => 'Dein Gebiet',
|
||||||
'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.',
|
'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.',
|
||||||
'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt',
|
'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt',
|
||||||
|
|
|
||||||
|
|
@ -1483,8 +1483,8 @@ class Translations$market$en {
|
||||||
/// en: 'Sharing setup'
|
/// en: 'Sharing setup'
|
||||||
String get configTitle => 'Sharing setup';
|
String get configTitle => 'Sharing setup';
|
||||||
|
|
||||||
/// 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.'
|
/// 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 — 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 — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.';
|
||||||
|
|
||||||
/// en: 'Your area'
|
/// en: 'Your area'
|
||||||
String get areaLabel => 'Your area';
|
String get areaLabel => 'Your area';
|
||||||
|
|
@ -3186,7 +3186,7 @@ extension on Translations {
|
||||||
'market.contact' => 'Message',
|
'market.contact' => 'Message',
|
||||||
'market.mine' => 'You',
|
'market.mine' => 'You',
|
||||||
'market.configTitle' => 'Sharing setup',
|
'market.configTitle' => 'Sharing setup',
|
||||||
'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.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.areaLabel' => 'Your area',
|
'market.areaLabel' => 'Your area',
|
||||||
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
|
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
|
||||||
'market.areaSet' => 'Your area is set — kept coarse, never your 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 contact => 'Mensaje';
|
||||||
@override String get mine => 'Tú';
|
@override String get mine => 'Tú';
|
||||||
@override String get configTitle => 'Configuración de compartir';
|
@override String get configTitle => 'Configuración de compartir';
|
||||||
@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 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 areaLabel => 'Tu zona';
|
@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 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';
|
@override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto';
|
||||||
|
|
@ -1924,7 +1924,7 @@ extension on TranslationsEs {
|
||||||
'market.contact' => 'Mensaje',
|
'market.contact' => 'Mensaje',
|
||||||
'market.mine' => 'Tú',
|
'market.mine' => 'Tú',
|
||||||
'market.configTitle' => 'Configuración de compartir',
|
'market.configTitle' => 'Configuración de compartir',
|
||||||
'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.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.areaLabel' => 'Tu zona',
|
'market.areaLabel' => 'Tu zona',
|
||||||
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
|
'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',
|
'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 contact => 'Message';
|
||||||
@override String get mine => 'Vous';
|
@override String get mine => 'Vous';
|
||||||
@override String get configTitle => 'Configuration du partage';
|
@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 — 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 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 areaLabel => 'Votre zone';
|
@override String get areaLabel => 'Votre zone';
|
||||||
@override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.';
|
@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';
|
@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.contact' => 'Message',
|
||||||
'market.mine' => 'Vous',
|
'market.mine' => 'Vous',
|
||||||
'market.configTitle' => 'Configuration du partage',
|
'market.configTitle' => 'Configuration du partage',
|
||||||
'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.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.areaLabel' => 'Votre zone',
|
'market.areaLabel' => 'Votre zone',
|
||||||
'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.',
|
'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.',
|
||||||
'market.areaSet' => 'Votre zone est définie — approximative, jamais votre 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 contact => 'Mensagem';
|
||||||
@override String get mine => 'Tu';
|
@override String get mine => 'Tu';
|
||||||
@override String get configTitle => 'Configuração de partilha';
|
@override String get configTitle => 'Configuração de partilha';
|
||||||
@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 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 areaLabel => 'A tua zona';
|
@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 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';
|
@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.contact' => 'Mensagem',
|
||||||
'market.mine' => 'Tu',
|
'market.mine' => 'Tu',
|
||||||
'market.configTitle' => 'Configuração de partilha',
|
'market.configTitle' => 'Configuração de partilha',
|
||||||
'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.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.areaLabel' => 'A tua zona',
|
'market.areaLabel' => 'A tua zona',
|
||||||
'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.',
|
'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',
|
'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 contact => 'Mensagem';
|
||||||
@override String get mine => 'Você';
|
@override String get mine => 'Você';
|
||||||
@override String get configTitle => 'Configuração de compartilha';
|
@override String get configTitle => 'Configuração de compartilha';
|
||||||
@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 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 areaLabel => 'A sua zona';
|
@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 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';
|
@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.contact' => 'Mensagem',
|
||||||
'market.mine' => 'Você',
|
'market.mine' => 'Você',
|
||||||
'market.configTitle' => 'Configuração de compartilha',
|
'market.configTitle' => 'Configuração de compartilha',
|
||||||
'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.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.areaLabel' => 'A sua zona',
|
'market.areaLabel' => 'A sua zona',
|
||||||
'market.areaHelp' => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.',
|
'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',
|
'market.areaSet' => 'A sua zona está definida — aproximada, nunca o seu ponto exato',
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,36 @@ class SocialConnection {
|
||||||
required SocialSettings settings,
|
required SocialSettings settings,
|
||||||
SessionOpener? open,
|
SessionOpener? open,
|
||||||
Stream<bool>? online,
|
Stream<bool>? online,
|
||||||
|
List<Duration>? retrySchedule,
|
||||||
}) : _settings = settings,
|
}) : _settings = settings,
|
||||||
_open = open ?? social.openSession,
|
_open = open ?? social.openSession,
|
||||||
_online = online;
|
_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),
|
||||||
|
];
|
||||||
|
|
||||||
final SocialSettings _settings;
|
final SocialSettings _settings;
|
||||||
final SessionOpener _open;
|
final SessionOpener _open;
|
||||||
final Stream<bool>? _online;
|
final Stream<bool>? _online;
|
||||||
|
final List<Duration> _retrySchedule;
|
||||||
|
|
||||||
final _sessions = StreamController<SocialSession?>.broadcast();
|
final _sessions = StreamController<SocialSession?>.broadcast();
|
||||||
SocialSession? _current;
|
SocialSession? _current;
|
||||||
Future<SocialSession?>? _pending;
|
Future<SocialSession?>? _pending;
|
||||||
StreamSubscription<bool>? _onlineSub;
|
StreamSubscription<bool>? _onlineSub;
|
||||||
bool _disposed = false;
|
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.
|
/// Emits the live session on each (re)connect, and null when it drops.
|
||||||
Stream<SocialSession?> get sessions => _sessions.stream;
|
Stream<SocialSession?> get sessions => _sessions.stream;
|
||||||
|
|
@ -48,10 +65,14 @@ class SocialConnection {
|
||||||
/// Begins watching connectivity (reconnect on regain, drop when offline) and
|
/// Begins watching connectivity (reconnect on regain, drop when offline) and
|
||||||
/// attempts an initial connect. Idempotent-ish; call once at startup.
|
/// attempts an initial connect. Idempotent-ish; call once at startup.
|
||||||
void start() {
|
void start() {
|
||||||
|
_started = true;
|
||||||
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
|
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
|
||||||
|
_knownOffline = !isOnline;
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
|
_cancelRetry();
|
||||||
_drop();
|
_drop();
|
||||||
} else if (_current == null) {
|
} else if (_current == null) {
|
||||||
|
_retryIndex = 0; // fresh network — start the backoff over
|
||||||
unawaited(session());
|
unawaited(session());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -75,15 +96,40 @@ class SocialConnection {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_current = s;
|
_current = s;
|
||||||
|
_retryIndex = 0;
|
||||||
|
_cancelRetry();
|
||||||
_sessions.add(s);
|
_sessions.add(s);
|
||||||
return s;
|
return s;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null; // unreachable — a later connectivity change retries
|
_scheduleRetry(); // unreachable — retry with backoff (see below)
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
_pending = null;
|
_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() {
|
void _drop() {
|
||||||
final s = _current;
|
final s = _current;
|
||||||
_current = null;
|
_current = null;
|
||||||
|
|
@ -95,6 +141,7 @@ class SocialConnection {
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
_cancelRetry();
|
||||||
await _onlineSub?.cancel();
|
await _onlineSub?.cancel();
|
||||||
_onlineSub = null;
|
_onlineSub = null;
|
||||||
_drop();
|
_drop();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../services/offer_mapper.dart';
|
||||||
import '../services/offer_outbox.dart';
|
import '../services/offer_outbox.dart';
|
||||||
import '../services/offer_thumbnail.dart';
|
import '../services/offer_thumbnail.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
|
import '../services/social_service.dart';
|
||||||
|
|
||||||
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
||||||
/// only what the UI shows, never a relay handle.
|
/// only what the UI shows, never a relay handle.
|
||||||
|
|
@ -28,6 +29,7 @@ class OffersState extends Equatable {
|
||||||
this.nextPageCursor,
|
this.nextPageCursor,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = false,
|
this.hasSearched = false,
|
||||||
|
this.connectionEpoch = 0,
|
||||||
this.error,
|
this.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -80,6 +82,11 @@ class OffersState extends Equatable {
|
||||||
/// from "searched, found nothing".
|
/// from "searched, found nothing".
|
||||||
final bool hasSearched;
|
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).
|
/// Last error, in human terms for the UI (null when fine).
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
|
|
@ -136,6 +143,7 @@ class OffersState extends Equatable {
|
||||||
int? Function()? nextPageCursor,
|
int? Function()? nextPageCursor,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
|
int? connectionEpoch,
|
||||||
String? Function()? error,
|
String? Function()? error,
|
||||||
}) {
|
}) {
|
||||||
return OffersState(
|
return OffersState(
|
||||||
|
|
@ -153,6 +161,7 @@ class OffersState extends Equatable {
|
||||||
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
|
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
|
connectionEpoch: connectionEpoch ?? this.connectionEpoch,
|
||||||
error: error != null ? error() : this.error,
|
error: error != null ? error() : this.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +181,7 @@ class OffersState extends Equatable {
|
||||||
nextPageCursor,
|
nextPageCursor,
|
||||||
publishing,
|
publishing,
|
||||||
hasSearched,
|
hasSearched,
|
||||||
|
connectionEpoch,
|
||||||
error,
|
error,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -183,15 +193,40 @@ class OffersState extends Equatable {
|
||||||
class OffersCubit extends Cubit<OffersState> {
|
class OffersCubit extends Cubit<OffersState> {
|
||||||
OffersCubit(
|
OffersCubit(
|
||||||
this._transport, {
|
this._transport, {
|
||||||
|
SocialConnection? connection,
|
||||||
Future<Uint8List?> Function(String varietyId)? coverPhoto,
|
Future<Uint8List?> Function(String varietyId)? coverPhoto,
|
||||||
String? Function(Uint8List bytes)? thumbnail,
|
String? Function(Uint8List bytes)? thumbnail,
|
||||||
Future<void> Function()? onDispose,
|
Future<void> Function()? onDispose,
|
||||||
}) : _coverPhoto = coverPhoto,
|
}) : _coverPhoto = coverPhoto,
|
||||||
_thumbnail = thumbnail,
|
_thumbnail = thumbnail,
|
||||||
_onDispose = onDispose,
|
_onDispose = onDispose,
|
||||||
super(const OffersState());
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
final OfferTransport? _transport;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
|
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
|
||||||
/// repo is wired.
|
/// repo is wired.
|
||||||
|
|
@ -225,6 +260,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
/// from now on — older results arrive via [loadNextPage], not by draining the
|
/// from now on — older results arrive via [loadNextPage], not by draining the
|
||||||
/// whole area into memory.
|
/// whole area into memory.
|
||||||
Future<void> discover(String geohashPrefix) async {
|
Future<void> discover(String geohashPrefix) async {
|
||||||
|
_lastPrefix = geohashPrefix;
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
if (transport == null) {
|
if (transport == null) {
|
||||||
emit(state.copyWith(error: () => 'offline', hasSearched: true));
|
emit(state.copyWith(error: () => 'offline', hasSearched: true));
|
||||||
|
|
@ -467,6 +503,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
_searchTimeout?.cancel();
|
_searchTimeout?.cancel();
|
||||||
|
await _connSub?.cancel();
|
||||||
await _sub?.cancel();
|
await _sub?.cancel();
|
||||||
await _onDispose?.call();
|
await _onDispose?.call();
|
||||||
return super.close();
|
return super.close();
|
||||||
|
|
@ -484,6 +521,7 @@ Future<OffersCubit> createOffersCubit(
|
||||||
final session = await connection.session();
|
final session = await connection.session();
|
||||||
return OffersCubit(
|
return OffersCubit(
|
||||||
session?.offers,
|
session?.offers,
|
||||||
|
connection: connection, // keeps following (re)connects — see _onSession
|
||||||
coverPhoto: repository?.coverPhotoFor,
|
coverPhoto: repository?.coverPhotoFor,
|
||||||
thumbnail: offerThumbnailDataUri,
|
thumbnail: offerThumbnailDataUri,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,11 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
// Only needed to flush the outbox; read here (while mounted) to avoid using
|
// 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).
|
// context across the awaits below. Null when there's no outbox (e.g. tests).
|
||||||
final repo =
|
final repo = widget.outbox != null
|
||||||
widget.outbox != null ? context.read<VarietyRepository>() : null;
|
? context.read<VarietyRepository>()
|
||||||
|
: null;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final cubit =
|
final cubit = await createOffersCubit(widget.connection, repository: repo);
|
||||||
await createOffersCubit(widget.connection, repository: repo);
|
|
||||||
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||||
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
|
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
|
|
@ -122,10 +122,10 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
await previous?.close();
|
await previous?.close();
|
||||||
if (area.isNotEmpty && cubit.isOnline) {
|
if (area.isNotEmpty) {
|
||||||
// Flush anything parked while offline, then show what's out there.
|
// Flush anything parked while offline, then show what's out there.
|
||||||
final outbox = widget.outbox;
|
final outbox = widget.outbox;
|
||||||
if (outbox != null && repo != null) {
|
if (outbox != null && repo != null && cubit.isOnline) {
|
||||||
await flushOutbox(
|
await flushOutbox(
|
||||||
outbox: outbox,
|
outbox: outbox,
|
||||||
cubit: cubit,
|
cubit: cubit,
|
||||||
|
|
@ -136,6 +136,8 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final precision = await widget.settings.searchPrecision();
|
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));
|
if (mounted) await cubit.discover(searchPrefix(area, precision));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,12 +208,18 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
|
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
|
||||||
// count == 0 with lots to share means every relay refused — say so plainly
|
// count == 0 with lots to share means every relay refused — say so plainly
|
||||||
// rather than "shared 0", which reads like success.
|
// rather than "shared 0", which reads like success.
|
||||||
messenger.showSnackBar(SnackBar(
|
messenger.showSnackBar(
|
||||||
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)),
|
SnackBar(
|
||||||
));
|
content: Text(
|
||||||
|
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
final precision = await widget.settings.searchPrecision();
|
final precision = await widget.settings.searchPrecision();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared
|
await cubit.discover(
|
||||||
|
searchPrefix(area, precision),
|
||||||
|
); // refresh incl. just-shared
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -240,13 +248,13 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
),
|
),
|
||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
cubit != null && (cubit.isOnline || widget.outbox != null)
|
cubit != null && (cubit.isOnline || widget.outbox != null)
|
||||||
? FloatingActionButton.extended(
|
? FloatingActionButton.extended(
|
||||||
key: const Key('market.shareMine'),
|
key: const Key('market.shareMine'),
|
||||||
onPressed: _shareMine,
|
onPressed: _shareMine,
|
||||||
icon: const Icon(Icons.campaign_outlined),
|
icon: const Icon(Icons.campaign_outlined),
|
||||||
label: Text(t.market.shareMine),
|
label: Text(t.market.shareMine),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: _loading || cubit == null
|
body: _loading || cubit == null
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: BlocProvider.value(
|
: BlocProvider.value(
|
||||||
|
|
@ -298,6 +306,17 @@ class MarketBody extends StatelessWidget {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return BlocBuilder<OffersCubit, OffersState>(
|
return BlocBuilder<OffersCubit, OffersState>(
|
||||||
builder: (context, state) {
|
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) {
|
if (!context.read<OffersCubit>().isOnline) {
|
||||||
// Default community servers exist, so "offline" means unreachable —
|
// Default community servers exist, so "offline" means unreachable —
|
||||||
// offer a retry rather than "set up sharing".
|
// offer a retry rather than "set up sharing".
|
||||||
|
|
@ -309,15 +328,6 @@ class MarketBody extends StatelessWidget {
|
||||||
onAction: onRetry,
|
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) {
|
if (state.searching && state.offers.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -325,8 +335,10 @@ class MarketBody extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(t.market.searching,
|
Text(
|
||||||
style: const TextStyle(color: seedMuted)),
|
t.market.searching,
|
||||||
|
style: const TextStyle(color: seedMuted),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -370,16 +382,22 @@ class MarketBody extends StatelessWidget {
|
||||||
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||||
// Offer to save the current search once it's worth alerting on
|
// Offer to save the current search once it's worth alerting on
|
||||||
// (some text typed or a chip active), never for the bare list.
|
// (some text typed or a chip active), never for the bare list.
|
||||||
suffixIcon: savedSearches != null &&
|
suffixIcon:
|
||||||
|
savedSearches != null &&
|
||||||
(state.query.trim().isNotEmpty ||
|
(state.query.trim().isNotEmpty ||
|
||||||
state.hasActiveFilter)
|
state.hasActiveFilter)
|
||||||
? IconButton(
|
? IconButton(
|
||||||
key: const Key('market.saveSearch'),
|
key: const Key('market.saveSearch'),
|
||||||
icon: const Icon(Icons.bookmark_add_outlined,
|
icon: const Icon(
|
||||||
color: seedGreen),
|
Icons.bookmark_add_outlined,
|
||||||
|
color: seedGreen,
|
||||||
|
),
|
||||||
tooltip: t.savedSearches.save,
|
tooltip: t.savedSearches.save,
|
||||||
onPressed: () =>
|
onPressed: () => _saveCurrentSearch(
|
||||||
_saveCurrentSearch(context, savedSearches!, state),
|
context,
|
||||||
|
savedSearches!,
|
||||||
|
state,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
hintStyle: const TextStyle(color: seedMuted),
|
hintStyle: const TextStyle(color: seedMuted),
|
||||||
|
|
@ -431,13 +449,17 @@ class MarketBody extends StatelessWidget {
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
// A trailing spinner row while the next page loads.
|
// A trailing spinner row while the next page loads.
|
||||||
itemCount: visible.length + (state.loadingMore ? 1 : 0),
|
itemCount:
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
visible.length + (state.loadingMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: 12),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
if (i >= visible.length) {
|
if (i >= visible.length) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final o = visible[i];
|
final o = visible[i];
|
||||||
|
|
@ -574,7 +596,9 @@ class _OfferCard extends StatelessWidget {
|
||||||
if (mine) ...[
|
if (mine) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 3),
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: seedGreen,
|
color: seedGreen,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
|
@ -598,8 +622,10 @@ class _OfferCard extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(t.market.near,
|
Text(
|
||||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
t.market.near,
|
||||||
|
style: const TextStyle(color: seedMuted, fontSize: 13),
|
||||||
|
),
|
||||||
if (offer.isOrganic) ...[
|
if (offer.isOrganic) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Icon(Icons.eco, size: 15, color: seedGreen),
|
const Icon(Icons.eco, size: 15, color: seedGreen),
|
||||||
|
|
@ -607,7 +633,8 @@ class _OfferCard extends StatelessWidget {
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||||
Text(
|
Text(
|
||||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'
|
||||||
|
.trim(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: seedOnSurface,
|
color: seedOnSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -906,194 +933,226 @@ class _ConfigSheetState extends State<_ConfigSheet> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final hasArea = _area.text.trim().isNotEmpty;
|
final hasArea = _area.text.trim().isNotEmpty;
|
||||||
return Padding(
|
// SafeArea keeps the Save button above the system nav bar on edge-to-edge
|
||||||
padding: EdgeInsets.only(
|
// devices; the viewInsets padding still lifts it above the keyboard.
|
||||||
left: 20,
|
return SafeArea(
|
||||||
right: 20,
|
top: false,
|
||||||
top: 20,
|
child: Padding(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
padding: EdgeInsets.only(
|
||||||
),
|
left: 20,
|
||||||
child: _loading
|
right: 20,
|
||||||
? const SizedBox(
|
top: 20,
|
||||||
height: 120, child: Center(child: CircularProgressIndicator()))
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||||
: SingleChildScrollView(
|
),
|
||||||
child: Column(
|
child: _loading
|
||||||
mainAxisSize: MainAxisSize.min,
|
? const SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
height: 120,
|
||||||
children: [
|
child: Center(child: CircularProgressIndicator()),
|
||||||
Text(
|
)
|
||||||
t.market.configTitle,
|
: SingleChildScrollView(
|
||||||
style: const TextStyle(
|
child: Column(
|
||||||
fontSize: 18,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontWeight: FontWeight.w600,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
color: seedTitle,
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
t.market.configTitle,
|
||||||
const SizedBox(height: 8),
|
style: const TextStyle(
|
||||||
Text(
|
fontSize: 18,
|
||||||
t.market.setupIntro,
|
fontWeight: FontWeight.w600,
|
||||||
style: const TextStyle(
|
color: seedTitle,
|
||||||
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),
|
|
||||||
),
|
|
||||||
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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Text(
|
||||||
children: [
|
t.market.setupIntro,
|
||||||
Icon(
|
style: const TextStyle(
|
||||||
hasArea
|
color: seedMuted,
|
||||||
? Icons.check_circle
|
fontSize: 13,
|
||||||
: Icons.pending_outlined,
|
height: 1.4,
|
||||||
size: 18,
|
|
||||||
color: hasArea ? seedGreen : seedMuted,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Expanded(
|
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(
|
child: Text(
|
||||||
hasArea ? t.market.areaSet : t.market.areaNotSet,
|
_locationError!,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: hasArea ? seedOnSurface : seedMuted,
|
color: Color(0xFFB3261E),
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 12),
|
||||||
),
|
Row(
|
||||||
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),
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
Icon(
|
||||||
key: const Key('market.area'),
|
hasArea ? Icons.check_circle : Icons.pending_outlined,
|
||||||
controller: _area,
|
size: 18,
|
||||||
onChanged: (_) => setState(() {}),
|
color: hasArea ? seedGreen : seedMuted,
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: t.market.areaCodeLabel,
|
|
||||||
helperText: t.market.areaCodeHint,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(width: 8),
|
||||||
Align(
|
Expanded(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
|
||||||
child: Text(
|
child: Text(
|
||||||
t.market.serversLabel,
|
hasArea ? t.market.areaSet : t.market.areaNotSet,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13, color: seedMuted),
|
color: hasArea ? seedOnSurface : seedMuted,
|
||||||
),
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
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: 16),
|
||||||
const SizedBox(height: 20),
|
Align(
|
||||||
FilledButton(
|
alignment: AlignmentDirectional.centerStart,
|
||||||
key: const Key('market.save'),
|
child: Text(
|
||||||
onPressed: _save,
|
t.market.rangeLabel,
|
||||||
child: Text(t.market.save),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: tane
|
name: tane
|
||||||
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
|
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.10+12
|
version: 0.1.12+14
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.5
|
sdk: ^3.11.5
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,13 @@ void main() {
|
||||||
required List<FakeChannel> opened,
|
required List<FakeChannel> opened,
|
||||||
Stream<bool>? online,
|
Stream<bool>? online,
|
||||||
bool Function()? fail,
|
bool Function()? fail,
|
||||||
|
List<Duration>? retrySchedule,
|
||||||
}) =>
|
}) =>
|
||||||
SocialConnection(
|
SocialConnection(
|
||||||
social: social,
|
social: social,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
online: online,
|
online: online,
|
||||||
|
retrySchedule: retrySchedule,
|
||||||
open: (_) async {
|
open: (_) async {
|
||||||
if (fail?.call() ?? false) throw StateError('unreachable');
|
if (fail?.call() ?? false) throw StateError('unreachable');
|
||||||
final ch = FakeChannel();
|
final ch = FakeChannel();
|
||||||
|
|
@ -106,6 +108,69 @@ void main() {
|
||||||
await online.close();
|
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',
|
test('returns null when the relay is unreachable, and retries later',
|
||||||
() async {
|
() async {
|
||||||
final opened = <FakeChannel>[];
|
final opened = <FakeChannel>[];
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import 'package:tane/data/variety_repository.dart';
|
||||||
import 'package:tane/db/enums.dart';
|
import 'package:tane/db/enums.dart';
|
||||||
import 'package:tane/services/discovery_area.dart';
|
import 'package:tane/services/discovery_area.dart';
|
||||||
import 'package:tane/services/offer_mapper.dart';
|
import 'package:tane/services/offer_mapper.dart';
|
||||||
|
import 'package:nostr/nostr.dart';
|
||||||
import 'package:tane/services/offer_outbox.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 'package:tane/state/offers_cubit.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
@ -835,4 +839,111 @@ void main() {
|
||||||
await cubit.close();
|
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,8 +84,9 @@ void main() {
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('MarketScreen with no relays configured degrades to offline',
|
testWidgets(
|
||||||
(tester) async {
|
'a fresh install (no area, unreachable) asks for the area first, '
|
||||||
|
'not the connection', (tester) async {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []); // offline: don't hit the network
|
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||||
|
|
@ -94,7 +95,23 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
|
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
|
||||||
expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry
|
// 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the range selector persists the chosen search precision',
|
testWidgets('the range selector persists the chosen search precision',
|
||||||
|
|
@ -148,6 +165,36 @@ void main() {
|
||||||
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
|
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',
|
testWidgets('"use my location" fills the area with a coarse geohash',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,10 @@ 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
|
- **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.
|
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:
|
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
|
||||||
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim,
|
o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
|
||||||
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser
|
há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
|
||||||
**descentralizado**).
|
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**).
|
||||||
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
|
- **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
|
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.
|
teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras.
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ 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.
|
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
|
||||||
Sin publicidad.
|
Sin publicidad.
|
||||||
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
|
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
|
||||||
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de
|
lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
|
||||||
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea
|
colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
|
||||||
**descentralizada**).
|
desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
|
||||||
|
en mano (eso es que sea **descentralizada**).
|
||||||
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
|
- **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
|
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.
|
tener que dar tu nombre real ni tu teléfono si no quieres.
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,10 @@ still do it with notebooks and spreadsheets.
|
||||||
- **Your data is yours.** Your information is kept on **your own phone**, protected with a
|
- **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.
|
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
|
- **No one can shut it down or control it.** There's no central computer that everything
|
||||||
passes through: people communicate **directly with each other**, like passing seeds from
|
passes through: what you share travels over **community servers**, run by people and
|
||||||
hand to hand. So no one can censor it or charge you to use it (that's what being
|
collectives — there are many, and anyone can add their own — so none of them is a center
|
||||||
**decentralized** means).
|
that could censor it or charge you to use it, like passing seeds from hand to hand
|
||||||
|
(that's what being **decentralized** means).
|
||||||
- **You trust by word of mouth.** You deal with people you know and with those vouched for
|
- **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
|
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.
|
having to give your real name or phone number unless you want to.
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,10 @@ class NostrOfferTransport implements OfferTransport {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
|
||||||
final events = await _conn.reqOnce(_filter(query))
|
// Newest first; the relays dedup within themselves and reqOnce dedups
|
||||||
// Newest first; the relays dedup within themselves and reqOnce dedups
|
// across them, but order is not guaranteed, so sort here — on a copy, as
|
||||||
// across them, but order is not guaranteed, so sort here.
|
// the channel may hand out an unmodifiable list.
|
||||||
|
final events = [...await _conn.reqOnce(_filter(query))]
|
||||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
final offers = <Offer>[];
|
final offers = <Offer>[];
|
||||||
for (final e in events) {
|
for (final e in events) {
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,32 @@ class RelayPool implements NostrChannel {
|
||||||
/// Number of relays currently connected (for diagnostics/tests).
|
/// Number of relays currently connected (for diagnostics/tests).
|
||||||
int get relayCount => _connections.length;
|
int get relayCount => _connections.length;
|
||||||
|
|
||||||
/// Connects to each of [relayUrls], skipping any that fail. Throws
|
/// Connects to each of [relayUrls], skipping any that fail or take longer
|
||||||
/// [StateError] only when NONE are reachable, so the caller can treat that as
|
/// than [connectTimeout] — a relay on a silently-filtering network would
|
||||||
/// "offline".
|
/// otherwise hang the whole pool for minutes. Throws [StateError] only when
|
||||||
|
/// NONE are reachable, so the caller can treat that as "offline".
|
||||||
static Future<RelayPool> connect(
|
static Future<RelayPool> connect(
|
||||||
List<String> relayUrls, {
|
List<String> relayUrls, {
|
||||||
required NostrIdentity identity,
|
required NostrIdentity identity,
|
||||||
|
Duration connectTimeout = const Duration(seconds: 10),
|
||||||
}) async {
|
}) async {
|
||||||
final connections = <NostrConnection>[];
|
final connections = <NostrConnection>[];
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
relayUrls.map((url) async {
|
relayUrls.map((url) async {
|
||||||
try {
|
try {
|
||||||
|
final attempt = NostrConnection.connect(url, identity: identity);
|
||||||
connections.add(
|
connections.add(
|
||||||
await NostrConnection.connect(url, identity: identity),
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Unreachable relay — skip it, keep the rest.
|
// Unreachable relay — skip it, keep the rest.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
|
@ -58,6 +59,38 @@ void main() {
|
||||||
await r1.stop();
|
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 {
|
test('throws when no relay is reachable (caller treats as offline)', () async {
|
||||||
final alice = await idFor(4);
|
final alice = await idFor(4);
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -121,11 +121,15 @@ a { color: var(--green); }
|
||||||
.lang-menu a.lang:hover { background: var(--container); }
|
.lang-menu a.lang:hover { background: var(--container); }
|
||||||
.lang-menu span.current { color: var(--muted); font-weight: 600; }
|
.lang-menu span.current { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
/* Narrow phones: brand on its own row, nav wraps beneath it, left-aligned. */
|
/* 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. */
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.site-header { gap: .5rem; }
|
.site-header { gap: .5rem; }
|
||||||
.brand { font-size: 1.15rem; }
|
.brand { font-size: 1.15rem; }
|
||||||
.site-nav { width: 100%; gap: .4rem 1rem; font-size: .95rem; }
|
.site-nav { gap: .4rem 1rem; font-size: .95rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); }
|
main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); }
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ values:
|
||||||
points:
|
points:
|
||||||
- "No center: no company can shut it down, censor it, or be fined for it."
|
- "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."
|
- "Anti-monopoly by design — sharing seeds multiplies the commons instead of depleting it."
|
||||||
- "Spreads like a seed, person to person, with no server in the middle."
|
- "Spreads like a seed, person to person; what you share travels through community servers anyone can run — not a company's central server."
|
||||||
- "Free software (AGPL-3.0). No ads, no commissions, no business model."
|
- "Free software (AGPL-3.0). No ads, no commissions, no business model."
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ values:
|
||||||
points:
|
points:
|
||||||
- "Sin centro: ninguna empresa puede apagarlo, censurarlo ni ser multada por ello."
|
- "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."
|
- "Anti-monopolio por diseño: compartir semillas multiplica el común en lugar de agotarlo."
|
||||||
- "Se propaga como una semilla, de persona a persona, sin servidor en medio."
|
- "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."
|
||||||
- "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio."
|
- "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio."
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ values:
|
||||||
points:
|
points:
|
||||||
- "Sem centro: nenhuma empresa o pode desligar, censurar ou multar por isso."
|
- "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."
|
- "Anti-monopólio por desenho — partilhar sementes multiplica os comuns em vez de os esgotar."
|
||||||
- "Espalha-se como uma semente, de pessoa a pessoa, sem servidor pelo meio."
|
- "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."
|
||||||
- "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio."
|
- "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio."
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,10 @@ still do it with notebooks and spreadsheets.
|
||||||
- **Your data is yours.** Your information is kept on **your own phone**, protected with a
|
- **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.
|
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
|
- **No one can shut it down or control it.** There's no central computer that everything
|
||||||
passes through: people communicate **directly with each other**, like passing seeds from
|
passes through: what you share travels over **community servers**, run by people and
|
||||||
hand to hand. So no one can censor it or charge you to use it (that's what being
|
collectives — there are many, and anyone can add their own — so none of them is a center
|
||||||
**decentralized** means).
|
that could censor it or charge you to use it, like passing seeds from hand to hand
|
||||||
|
(that's what being **decentralized** means).
|
||||||
- **You trust by word of mouth.** You deal with people you know and with those vouched for
|
- **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
|
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.
|
having to give your real name or phone number unless you want to.
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,10 @@ 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.
|
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
|
||||||
Sin publicidad.
|
Sin publicidad.
|
||||||
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
|
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
|
||||||
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de
|
lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
|
||||||
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea
|
colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
|
||||||
**descentralizada**).
|
desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
|
||||||
|
en mano (eso es que sea **descentralizada**).
|
||||||
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
|
- **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
|
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.
|
tener que dar tu nombre real ni tu teléfono si no quieres.
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,10 @@ 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
|
- **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.
|
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:
|
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
|
||||||
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim,
|
o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
|
||||||
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser
|
há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
|
||||||
**descentralizado**).
|
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**).
|
||||||
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
|
- **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
|
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.
|
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