feat(block2): global 'you're offline' banner (network state)

Feedback: offline, the social layer stops working with no clear reason. A slim
amber strip now shows above every screen when there's no network (connectivity_
plus), so it's obvious WHY sharing is paused — the inventory keeps working.
Wrapped once via the MaterialApp builder; connectivity check is guarded so it
no-ops where the plugin is absent (tests/unsupported platforms). i18n en/es/pt.
Analyzer clean.
This commit is contained in:
vjrj 2026-07-10 15:18:16 +02:00
parent 5741d6bde8
commit 1ac954a09e
11 changed files with 135 additions and 5 deletions

View file

@ -26,6 +26,7 @@ import 'ui/home_screen.dart';
import 'ui/intro_screen.dart'; import 'ui/intro_screen.dart';
import 'ui/inventory_list_screen.dart'; import 'ui/inventory_list_screen.dart';
import 'ui/market_screen.dart'; import 'ui/market_screen.dart';
import 'ui/offline_banner.dart';
import 'ui/profile_screen.dart'; import 'ui/profile_screen.dart';
import 'ui/settings_screen.dart'; import 'ui/settings_screen.dart';
import 'ui/theme.dart'; import 'ui/theme.dart';
@ -212,6 +213,10 @@ class TaneApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
// A global "you're offline" strip above every screen makes it clear
// why the social layer is paused (the inventory still works).
builder: (context, child) =>
OfflineBanner(child: child ?? const SizedBox.shrink()),
routerConfig: _router, routerConfig: _router,
), ),
), ),

View file

@ -8,7 +8,8 @@
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"type": "Type", "type": "Type",
"comingSoon": "Coming soon" "comingSoon": "Coming soon",
"offline": "You're offline — sharing is paused"
}, },
"home": { "home": {
"tagline": "Share and grow local seeds", "tagline": "Share and grow local seeds",

View file

@ -8,7 +8,8 @@
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"type": "Tipo", "type": "Tipo",
"comingSoon": "Pronto" "comingSoon": "Pronto",
"offline": "Sin conexión — el compartir está en pausa"
}, },
"home": { "home": {
"tagline": "Comparte y cultiva semillas locales", "tagline": "Comparte y cultiva semillas locales",

View file

@ -8,7 +8,8 @@
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"type": "Tipo", "type": "Tipo",
"comingSoon": "Em breve" "comingSoon": "Em breve",
"offline": "Sem ligação — a partilha está em pausa"
}, },
"home": { "home": {
"tagline": "Partilha e cultiva sementes locais", "tagline": "Partilha e cultiva sementes locais",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 3 /// Locales: 3
/// Strings: 1073 (357 per locale) /// Strings: 1076 (358 per locale)
/// ///
/// Built on 2026-07-10 at 11:35 UTC /// Built on 2026-07-10 at 13:17 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -113,6 +113,9 @@ class Translations$common$en {
/// en: 'Coming soon' /// en: 'Coming soon'
String get comingSoon => 'Coming soon'; String get comingSoon => 'Coming soon';
/// en: 'You're offline sharing is paused'
String get offline => 'You\'re offline — sharing is paused';
} }
// Path: home // Path: home
@ -1755,6 +1758,7 @@ extension on Translations {
'common.edit' => 'Edit', 'common.edit' => 'Edit',
'common.type' => 'Type', 'common.type' => 'Type',
'common.comingSoon' => 'Coming soon', 'common.comingSoon' => 'Coming soon',
'common.offline' => 'You\'re offline — sharing is paused',
'home.tagline' => 'Share and grow local seeds', 'home.tagline' => 'Share and grow local seeds',
'home.openMarket' => 'Market', 'home.openMarket' => 'Market',
'home.openMarketSubtitle' => 'Discover and share seeds nearby', 'home.openMarketSubtitle' => 'Discover and share seeds nearby',

View file

@ -98,6 +98,7 @@ class _Translations$common$es extends Translations$common$en {
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Tipo'; @override String get type => 'Tipo';
@override String get comingSoon => 'Pronto'; @override String get comingSoon => 'Pronto';
@override String get offline => 'Sin conexión — el compartir está en pausa';
} }
// Path: home // Path: home
@ -1057,6 +1058,7 @@ extension on TranslationsEs {
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Tipo', 'common.type' => 'Tipo',
'common.comingSoon' => 'Pronto', 'common.comingSoon' => 'Pronto',
'common.offline' => 'Sin conexión — el compartir está en pausa',
'home.tagline' => 'Comparte y cultiva semillas locales', 'home.tagline' => 'Comparte y cultiva semillas locales',
'home.openMarket' => 'Mercado', 'home.openMarket' => 'Mercado',
'home.openMarketSubtitle' => 'Descubre y comparte semillas cerca', 'home.openMarketSubtitle' => 'Descubre y comparte semillas cerca',

View file

@ -98,6 +98,7 @@ class _Translations$common$pt extends Translations$common$en {
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Tipo'; @override String get type => 'Tipo';
@override String get comingSoon => 'Em breve'; @override String get comingSoon => 'Em breve';
@override String get offline => 'Sem ligação — a partilha está em pausa';
} }
// Path: home // Path: home
@ -1053,6 +1054,7 @@ extension on TranslationsPt {
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Tipo', 'common.type' => 'Tipo',
'common.comingSoon' => 'Em breve', 'common.comingSoon' => 'Em breve',
'common.offline' => 'Sem ligação — a partilha está em pausa',
'home.tagline' => 'Partilha e cultiva sementes locais', 'home.tagline' => 'Partilha e cultiva sementes locais',
'home.openMarket' => 'Mercado', 'home.openMarket' => 'Mercado',
'home.openMarketSubtitle' => 'Descobre e partilha sementes por perto', 'home.openMarketSubtitle' => 'Descobre e partilha sementes por perto',

View file

@ -0,0 +1,87 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
/// Wraps the app and shows a slim strip at the very top when there's no network,
/// so it's clear WHY the social layer (market, chat, profile) is paused — the
/// inventory keeps working offline. Wrap the whole app once (MaterialApp builder).
class OfflineBanner extends StatefulWidget {
const OfflineBanner({required this.child, super.key});
final Widget child;
@override
State<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends State<OfflineBanner> {
bool _offline = false;
StreamSubscription<List<ConnectivityResult>>? _sub;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final current = await Connectivity().checkConnectivity();
if (mounted) setState(() => _offline = _isOffline(current));
_sub = Connectivity().onConnectivityChanged.listen((results) {
if (mounted) setState(() => _offline = _isOffline(results));
});
} catch (_) {
// Platform without connectivity support assume online, hide the banner.
}
}
bool _isOffline(List<ConnectivityResult> results) =>
results.isEmpty ||
results.every((r) => r == ConnectivityResult.none);
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_offline)
Material(
color: const Color(0xFFE8A200), // amber not an error, just paused
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 16, color: Colors.white),
const SizedBox(width: 8),
Flexible(
child: Text(
context.t.common.offline,
style: const TextStyle(
color: Colors.white,
fontSize: 12.5,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
Expanded(child: widget.child),
],
);
}
}

View file

@ -71,6 +71,9 @@ dependencies:
# typing a code. Low accuracy only; the value is immediately reduced to a # typing a code. Low accuracy only; the value is immediately reduced to a
# low-precision geohash. Behind an interface so it's fakeable and pluggable. # low-precision geohash. Behind an interface so it's fakeable and pluggable.
geolocator: ^13.0.1 geolocator: ^13.0.1
# Network reachability (BSD-3) to show a global "you're offline" banner — the
# social layer needs a connection; the inventory works regardless.
connectivity_plus: ^6.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -209,6 +209,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -860,6 +876,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description: