diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..83ad4d8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,6 @@ android.useAndroidX=true android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false +# Le cache incrémental Kotlin plante quand le pub cache (C:) et le projet (D:) +# sont sur des disques différents : il ne peut pas calculer de chemin relatif. +kotlin.incremental=false diff --git a/lib/main.dart b/lib/main.dart index e955d90..91ebc21 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'screens/home_shell.dart'; -import 'services/produit_repository.dart'; +import 'screens/demarrage_screen.dart'; import 'theme.dart'; Future main() async { @@ -14,9 +13,8 @@ Future main() async { DeviceOrientation.portraitDown, ]); - // Connexion au serveur (Raspberry Pi) + chargement + temps réel. - await ProduitRepository.instance.initialiser(); - + // On affiche l'interface tout de suite : la connexion au serveur se fait + // dans DemarrageScreen, loader visible (plus d'écran blanc au lancement). runApp(const MonApp()); } @@ -31,7 +29,7 @@ class MonApp extends StatelessWidget { theme: AppTheme.clair(), darkTheme: AppTheme.sombre(), themeMode: ThemeMode.light, - home: const HomeShell(), + home: const DemarrageScreen(), ); } } diff --git a/lib/screens/demarrage_screen.dart b/lib/screens/demarrage_screen.dart new file mode 100644 index 0000000..3da4eea --- /dev/null +++ b/lib/screens/demarrage_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../services/produit_repository.dart'; +import '../theme.dart'; +import 'home_shell.dart'; + +/// Écran affiché au lancement pendant la connexion au serveur. +/// Évite l'écran blanc : on voit le loader et l'étape en cours, et en cas +/// d'échec on peut réessayer ou continuer avec les données hors ligne. +class DemarrageScreen extends StatefulWidget { + const DemarrageScreen({super.key}); + + @override + State createState() => _DemarrageScreenState(); +} + +class _DemarrageScreenState extends State { + bool _fini = false; + + @override + void initState() { + super.initState(); + _lancer(); + } + + Future _lancer() async { + final repo = ProduitRepository.instance; + await repo.initialiser(); + if (!mounted) return; + // Serveur joignable (ou cache déjà rempli) : on entre directement dans l'app. + if (repo.connecte || repo.charge) { + setState(() => _fini = true); + } else { + setState(() {}); // affiche l'erreur + bouton Réessayer + } + } + + Future _reessayer() async { + await ProduitRepository.instance.charger(); + if (!mounted) return; + final repo = ProduitRepository.instance; + if (repo.connecte || repo.charge) setState(() => _fini = true); + } + + @override + Widget build(BuildContext context) { + if (_fini) return const HomeShell(); + + final repo = ProduitRepository.instance; + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: ListenableBuilder( + listenable: repo, + builder: (context, _) { + final echec = !repo.enCours && repo.erreur != null; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + echec ? Icons.cloud_off_rounded : Icons.storefront_rounded, + size: 56, + color: echec ? const Color(0xFFB26B00) : AppTheme.accent, + ), + const SizedBox(height: 20), + const Text( + 'Gestion Prix', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 36), + if (echec) ..._echec(repo) else ..._chargement(repo), + ], + ); + }, + ), + ), + ), + ), + ), + ); + } + + /// Loader + étape en cours juste en dessous. + List _chargement(ProduitRepository repo) => [ + const SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator(strokeWidth: 3), + ), + const SizedBox(height: 18), + Text( + repo.etape.isEmpty ? 'Démarrage…' : repo.etape, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w500, + color: AppTheme.grisTexte, + ), + ), + const SizedBox(height: 6), + Text( + repo.serveurUrl, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: AppTheme.grisTexte), + ), + ]; + + /// Serveur injoignable et aucun produit en cache : on explique et on propose. + List _echec(ProduitRepository repo) => [ + const Text( + 'Serveur injoignable', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + repo.serveurUrl, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12.5, color: AppTheme.grisTexte), + ), + const SizedBox(height: 10), + const Text( + 'Vérifie que le serveur est allumé et que la tablette est bien ' + 'connectée au réseau (Tailscale actif).', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppTheme.grisTexte), + ), + const SizedBox(height: 28), + FilledButton.icon( + onPressed: _reessayer, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () => setState(() => _fini = true), + child: const Text('Continuer hors ligne'), + ), + ]; +} diff --git a/lib/services/produit_repository.dart b/lib/services/produit_repository.dart index 11a42c5..be90828 100644 --- a/lib/services/produit_repository.dart +++ b/lib/services/produit_repository.dart @@ -20,6 +20,10 @@ class ProduitRepository extends ChangeNotifier { static const _collection = 'produits'; + /// Au-delà, on considère le serveur injoignable (évite d'attendre indéfiniment + /// au démarrage quand le Raspberry Pi est éteint ou hors réseau). + static const _delaiMax = Duration(seconds: 12); + PocketBase? _pb; String _serveurUrl = ''; UnsubscribeFunc? _annulerAbo; @@ -29,11 +33,20 @@ class ProduitRepository extends ChangeNotifier { bool _enCours = false; bool _connecte = false; String? _erreur; + String _etape = ''; List get produits => List.unmodifiable(_produits); bool get charge => _charge; bool get enCours => _enCours; + /// Étape en cours du démarrage, affichée sous le loader de l'écran d'accueil. + String get etape => _etape; + + void _setEtape(String texte) { + _etape = texte; + notifyListeners(); + } + /// Vrai si la dernière synchro avec le serveur a réussi (temps réel actif). bool get connecte => _connecte; String? get erreur => _erreur; @@ -43,9 +56,13 @@ class ProduitRepository extends ChangeNotifier { /// aux mises à jour temps réel. Ne lève jamais : en cas d'échec, [erreur] /// est renseigné pour que l'écran propose de réessayer. Future initialiser() async { + _setEtape('Lecture des réglages…'); _serveurUrl = await Reglages.instance.serveurUrl(); _pb = PocketBase(_serveurUrl); + + _setEtape('Chargement des produits enregistrés…'); await _chargerCache(); // affichage instantané + hors ligne + await charger(); // serveur + temps réel } @@ -98,21 +115,33 @@ class ProduitRepository extends ChangeNotifier { Future charger() async { _enCours = true; _erreur = null; - notifyListeners(); + _setEtape('Connexion au serveur…'); try { - final records = - await _pb!.collection(_collection).getFullList(sort: '-created'); + final records = await _pb! + .collection(_collection) + .getFullList(sort: '-created') + .timeout(_delaiMax); + + _setEtape('Récupération des produits…'); _produits ..clear() ..addAll(records.map(_versProduit)); _charge = true; _connecte = true; + + _setEtape('Enregistrement hors ligne…'); await _sauverCache(); - if (_annulerAbo == null) await _abonner(); + + if (_annulerAbo == null) { + _setEtape('Activation du temps réel…'); + await _abonner(); + } + _setEtape('Prêt'); } catch (e) { _connecte = false; // On garde les produits en cache ; le bandeau « non synchronisé » suffit. if (_produits.isEmpty) _erreur = 'Serveur injoignable ($_serveurUrl).\n$e'; + _setEtape('Hors ligne'); } finally { _enCours = false; notifyListeners(); @@ -132,7 +161,7 @@ class ProduitRepository extends ChangeNotifier { _connecte = true; unawaited(_sauverCache()); notifyListeners(); - }); + }).timeout(_delaiMax); } catch (_) { // Pas de temps réel (serveur momentanément indisponible) : pas bloquant, // les données se rechargeront à la prochaine ouverture / réessai. diff --git a/lib/services/reglages.dart b/lib/services/reglages.dart index 333e683..e11adff 100644 --- a/lib/services/reglages.dart +++ b/lib/services/reglages.dart @@ -10,8 +10,15 @@ class Reglages { static const _cleDerniereUnite = 'derniere_unite'; static const _cleServeur = 'serveur_url'; - /// Adresse par défaut du serveur PocketBase (le Raspberry Pi du magasin). - static const serveurParDefaut = 'http://192.168.1.32:8090'; + /// Adresse par défaut du serveur PocketBase (le Raspberry Pi du magasin, + /// exposé via Tailscale : accessible aussi bien au magasin qu'à l'extérieur). + static const serveurParDefaut = 'https://db.tailb756e1.ts.net'; + + /// Anciennes adresses par défaut : si la tablette a encore l'une d'elles + /// enregistrée, on bascule automatiquement sur la nouvelle. + static const _anciensDefauts = { + 'http://192.168.1.32:8090', + }; SharedPreferences? _prefs; @@ -19,8 +26,16 @@ class Reglages { _prefs ??= await SharedPreferences.getInstance(); /// Adresse du serveur PocketBase (modifiable dans les réglages). - Future serveurUrl() async => - (await _p).getString(_cleServeur) ?? serveurParDefaut; + Future serveurUrl() async { + final p = await _p; + final url = p.getString(_cleServeur); + if (url == null || _anciensDefauts.contains(url)) { + // Jamais réglé, ou ancienne adresse par défaut restée en mémoire. + if (url != null) await p.setString(_cleServeur, serveurParDefaut); + return serveurParDefaut; + } + return url; + } Future setServeurUrl(String url) async => (await _p).setString(_cleServeur, url.trim());