diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 21f2bcf..16d4cce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> main() async { DeviceOrientation.portraitDown, ]); - // Chargement initial des produits depuis la base locale. - await ProduitRepository.instance.charger(); + // Connexion au serveur (Raspberry Pi) + chargement + temps réel. + await ProduitRepository.instance.initialiser(); runApp(const MonApp()); } diff --git a/lib/screens/home_shell.dart b/lib/screens/home_shell.dart index 78c58ec..be5754b 100644 --- a/lib/screens/home_shell.dart +++ b/lib/screens/home_shell.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../services/produit_repository.dart'; import 'etiquettes_screen.dart'; import 'produits_screen.dart'; import 'scan_screen.dart'; @@ -23,7 +24,19 @@ class _HomeShellState extends State { const EtiquettesScreen(), ]; return Scaffold( - body: IndexedStack(index: _index, children: pages), + body: Column( + children: [ + ListenableBuilder( + listenable: ProduitRepository.instance, + builder: (context, _) { + final repo = ProduitRepository.instance; + if (repo.connecte) return const SizedBox.shrink(); + return _BanniereHorsLigne(onReessayer: () => repo.charger()); + }, + ), + Expanded(child: IndexedStack(index: _index, children: pages)), + ], + ), bottomNavigationBar: NavigationBar( selectedIndex: _index, height: 68, @@ -50,3 +63,49 @@ class _HomeShellState extends State { ); } } + +/// Fin bandeau affiché en haut quand la tablette n'est pas synchronisée au serveur. +class _BanniereHorsLigne extends StatelessWidget { + final VoidCallback onReessayer; + const _BanniereHorsLigne({required this.onReessayer}); + + static const _ambre = Color(0xFFB26B00); + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xFFFFF3E0), + child: SafeArea( + bottom: false, + child: InkWell( + onTap: onReessayer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + child: Row( + children: [ + const Icon(Icons.cloud_off_rounded, size: 16, color: _ambre), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Hors ligne — non synchronisé', + style: TextStyle( + color: _ambre, + fontSize: 12.5, + fontWeight: FontWeight.w600), + ), + ), + const Text('Réessayer', + style: TextStyle( + color: _ambre, + fontSize: 12.5, + fontWeight: FontWeight.w700)), + const SizedBox(width: 4), + const Icon(Icons.refresh, size: 15, color: _ambre), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/produits_screen.dart b/lib/screens/produits_screen.dart index 7273368..011f7fd 100644 --- a/lib/screens/produits_screen.dart +++ b/lib/screens/produits_screen.dart @@ -60,7 +60,12 @@ class _ProduitsScreenState extends State { return CustomScrollView( slivers: [ SliverToBoxAdapter(child: _entete(_repo.produits.length)), - if (_repo.enCours && _repo.produits.isEmpty) + if (_repo.erreur != null && _repo.produits.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _erreurServeur(), + ) + else if (_repo.enCours && _repo.produits.isEmpty) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) @@ -111,6 +116,12 @@ class _ProduitsScreenState extends State { fontWeight: FontWeight.w600, color: AppTheme.grisTexte)), const Spacer(), + IconButton( + icon: const Icon(Icons.settings_outlined), + color: AppTheme.grisTexte, + onPressed: _ouvrirReglages, + tooltip: 'Serveur', + ), ], ), const SizedBox(height: 14), @@ -177,6 +188,150 @@ class _ProduitsScreenState extends State { ], ); } + + Widget _erreurServeur() { + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.wifi_off_rounded, size: 64, color: Colors.red), + const SizedBox(height: 16), + const Text('Serveur injoignable', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + _repo.serveurUrl, + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.grisTexte, fontSize: 13), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + alignment: WrapAlignment.center, + children: [ + FilledButton.icon( + onPressed: () => _repo.charger(), + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + OutlinedButton.icon( + onPressed: _ouvrirReglages, + icon: const Icon(Icons.settings_outlined), + label: const Text('Réglages'), + ), + ], + ), + ], + ), + ); + } + + Future _ouvrirReglages() async { + final ctrl = TextEditingController(text: _repo.serveurUrl); + final locaux = await _repo.produitsLocaux(); + if (!mounted) return; + + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Serveur'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: (_repo.connecte ? const Color(0xFF1B873F) : Colors.red) + .withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.circle, + size: 12, + color: + _repo.connecte ? const Color(0xFF1B873F) : Colors.red), + const SizedBox(width: 10), + Text( + _repo.connecte ? 'Connecté au serveur' : 'Non connecté', + style: TextStyle( + fontWeight: FontWeight.w700, + color: + _repo.connecte ? const Color(0xFF1B873F) : Colors.red, + ), + ), + ], + ), + ), + const SizedBox(height: 18), + const Text('Adresse du serveur (Raspberry Pi)', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 8), + TextField( + controller: ctrl, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'http://192.168.1.32:8090', + ), + ), + if (locaux.isNotEmpty) ...[ + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 8), + Text('${locaux.length} produit(s) dans la base locale à envoyer', + style: const TextStyle(fontSize: 13)), + const SizedBox(height: 8), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: AppTheme.accent, + minimumSize: const Size.fromHeight(46)), + onPressed: () async { + Navigator.pop(ctx); + await _importerLocaux(); + }, + icon: const Icon(Icons.sync), + label: const Text('Lancer la synchro'), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Fermer'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(ctx); + await _repo.changerServeur(ctrl.text); + }, + child: const Text('Connecter'), + ), + ], + ), + ); + } + + Future _importerLocaux() async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Envoi vers le serveur…')), + ); + try { + final n = await _repo.importerLocaux(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$n produit(s) envoyé(s) au serveur ✓')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Échec de l\'envoi : $e')), + ); + } + } } class _ProduitCard extends StatelessWidget { diff --git a/lib/services/local_db.dart b/lib/services/local_db.dart index f8e91a4..e90f4b7 100644 --- a/lib/services/local_db.dart +++ b/lib/services/local_db.dart @@ -16,9 +16,10 @@ class LocalDb { final chemin = '$dir/gestion_prix.db'; return openDatabase( chemin, - version: 6, + version: 7, onCreate: (db, version) async { await _creerTable(db); + await _creerCache(db); }, onUpgrade: (db, ancienne, nouvelle) async { if (ancienne < 2) { @@ -45,10 +46,24 @@ class LocalDb { await db.execute( 'ALTER TABLE produits ADD COLUMN prix_achat REAL NOT NULL DEFAULT 0'); } + if (ancienne < 7) { + // Cache local des produits du serveur (lecture hors ligne). + await _creerCache(db); + } }, ); } + /// Cache des produits du serveur (clé = id serveur, valeur = JSON du produit). + Future _creerCache(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS cache_produits( + id TEXT PRIMARY KEY, + json TEXT NOT NULL + ) + '''); + } + Future _creerTable(Database db) async { await db.execute(''' CREATE TABLE produits( diff --git a/lib/services/produit_repository.dart b/lib/services/produit_repository.dart index d9254ca..11a42c5 100644 --- a/lib/services/produit_repository.dart +++ b/lib/services/produit_repository.dart @@ -1,47 +1,153 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/foundation.dart'; -import 'package:sqflite/sqflite.dart'; +import 'package:http/http.dart' as http; +import 'package:pocketbase/pocketbase.dart'; import '../models/produit.dart'; import 'local_db.dart'; +import 'photo_service.dart'; +import 'reglages.dart'; -/// Source de vérité des produits, partagée par tous les écrans. -/// Persistée localement dans une base SQLite (aucun serveur, fonctionne hors-ligne). +/// Source de vérité des produits, synchronisée en temps réel avec le serveur +/// PocketBase (le Raspberry Pi du magasin). Toutes les tablettes partagent +/// les mêmes données et se mettent à jour automatiquement. class ProduitRepository extends ChangeNotifier { ProduitRepository._(); static final ProduitRepository instance = ProduitRepository._(); + static const _collection = 'produits'; + + PocketBase? _pb; + String _serveurUrl = ''; + UnsubscribeFunc? _annulerAbo; + final List _produits = []; bool _charge = false; bool _enCours = false; + bool _connecte = false; String? _erreur; List get produits => List.unmodifiable(_produits); bool get charge => _charge; bool get enCours => _enCours; - String? get erreur => _erreur; - Future get _db async => LocalDb.instance.database; + /// Vrai si la dernière synchro avec le serveur a réussi (temps réel actif). + bool get connecte => _connecte; + String? get erreur => _erreur; + String get serveurUrl => _serveurUrl; + + /// Initialise la connexion au serveur puis charge les produits et s'abonne + /// 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 { + _serveurUrl = await Reglages.instance.serveurUrl(); + _pb = PocketBase(_serveurUrl); + await _chargerCache(); // affichage instantané + hors ligne + await charger(); // serveur + temps réel + } + + /// Charge les produits depuis le cache local (dernier état connu du serveur). + Future _chargerCache() async { + try { + final db = await LocalDb.instance.database; + final rows = await db.query('cache_produits'); + final list = rows + .map((r) => Produit.fromMap( + jsonDecode(r['json'] as String) as Map)) + .toList(); + if (list.isNotEmpty) { + _produits + ..clear() + ..addAll(list); + _charge = true; + notifyListeners(); + } + } catch (_) { + // Pas de cache : on attend le serveur. + } + } + + Future _sauverCache() async { + try { + final db = await LocalDb.instance.database; + final batch = db.batch(); + batch.delete('cache_produits'); + for (final p in _produits) { + batch.insert('cache_produits', { + 'id': p.id, + 'json': jsonEncode({...p.toInsertMap(), 'id': p.id, 'cree_le': p.creeLe}), + }); + } + await batch.commit(noResult: true); + } catch (_) {} + } + + /// Change l'adresse du serveur et recharge tout. + Future changerServeur(String url) async { + await Reglages.instance.setServeurUrl(url); + await _annulerAbo?.call(); + _annulerAbo = null; + _produits.clear(); + _charge = false; + await initialiser(); + } Future charger() async { _enCours = true; _erreur = null; notifyListeners(); - try { - final db = await _db; - final rows = await db.query('produits', orderBy: 'nom COLLATE NOCASE ASC'); + final records = + await _pb!.collection(_collection).getFullList(sort: '-created'); _produits ..clear() - ..addAll(rows.map(Produit.fromMap)); + ..addAll(records.map(_versProduit)); _charge = true; + _connecte = true; + await _sauverCache(); + if (_annulerAbo == null) await _abonner(); } catch (e) { - _erreur = e.toString(); + _connecte = false; + // On garde les produits en cache ; le bandeau « non synchronisé » suffit. + if (_produits.isEmpty) _erreur = 'Serveur injoignable ($_serveurUrl).\n$e'; } finally { _enCours = false; notifyListeners(); } } + Future _abonner() async { + try { + _annulerAbo = await _pb!.collection(_collection).subscribe('*', (e) { + final r = e.record; + if (r == null) return; + if (e.action == 'delete') { + _produits.removeWhere((p) => p.id == r.id); + } else { + _appliquer(_versProduit(r)); + } + _connecte = true; + unawaited(_sauverCache()); + notifyListeners(); + }); + } catch (_) { + // Pas de temps réel (serveur momentanément indisponible) : pas bloquant, + // les données se rechargeront à la prochaine ouverture / réessai. + } + } + + void _appliquer(Produit p) { + final i = _produits.indexWhere((e) => e.id == p.id); + if (i >= 0) { + _produits[i] = p; + } else { + _produits.add(p); + } + } + Produit? parCodeBarres(String code) { for (final p in _produits) { if (p.codeBarres == code) return p; @@ -50,42 +156,118 @@ class ProduitRepository extends ChangeNotifier { } Future ajouter(Produit p) async { - final db = await _db; - final ts = DateTime.now().millisecondsSinceEpoch; - final data = p.toInsertMap()..['cree_le'] = ts; - final id = await db.insert( - 'produits', - data, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - final cree = p.copyWith(id: id.toString(), creeLe: ts); - _produits.add(cree); - _trier(); + final r = await _pb!.collection(_collection).create( + body: _versBody(p), + files: await _fichiersImage(p.imageUrl), + ); + final cree = _versProduit(r); + _appliquer(cree); + unawaited(_sauverCache()); notifyListeners(); return cree; } Future modifier(Produit p) async { - final db = await _db; - await db.update( - 'produits', - p.toInsertMap(), - where: 'id = ?', - whereArgs: [int.tryParse(p.id) ?? p.id], - ); - final i = _produits.indexWhere((e) => e.id == p.id); - if (i != -1) _produits[i] = p; - _trier(); + final r = await _pb!.collection(_collection).update( + p.id, + body: _versBody(p), + files: await _fichiersImage(p.imageUrl), + ); + _appliquer(_versProduit(r)); + unawaited(_sauverCache()); notifyListeners(); } Future supprimer(String id) async { - final db = await _db; - await db.delete('produits', where: 'id = ?', whereArgs: [int.tryParse(id) ?? id]); + await _pb!.collection(_collection).delete(id); _produits.removeWhere((e) => e.id == id); + unawaited(_sauverCache()); notifyListeners(); } - void _trier() => _produits - .sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase())); + // ---- Mapping PocketBase <-> Produit ---------------------------------- + + Produit _versProduit(RecordModel r) { + // Image : photo custom (fichier PocketBase) en priorité, sinon lien web. + final fichier = r.getStringValue('image'); + String? image; + if (fichier.isNotEmpty) { + image = _pb!.files.getURL(r, fichier).toString(); + } else { + final url = r.getStringValue('image_url'); + image = url.isNotEmpty ? url : null; + } + + final q = (r.data['quantite'] as num?)?.toDouble() ?? 0; + final nb = (r.data['nb_contenants'] as num?)?.toInt() ?? 0; + + return Produit( + id: r.id, + codeBarres: _nonVide(r.getStringValue('code_barres')), + nom: r.getStringValue('nom'), + imageUrl: image, + prix: r.getDoubleValue('prix'), + prixAchat: r.getDoubleValue('prix_achat'), + quantite: q > 0 ? q : null, + unite: _nonVide(r.getStringValue('unite')), + nbContenants: nb > 1 ? nb : null, + stock: r.getIntValue('stock'), + creeLe: + DateTime.tryParse(r.getStringValue('created'))?.millisecondsSinceEpoch ?? + 0, + ); + } + + Map _versBody(Produit p) { + final photoLocale = PhotoService.estLocale(p.imageUrl); + return { + 'nom': p.nom, + 'code_barres': p.codeBarres ?? '', + 'prix': p.prix, + 'prix_achat': p.prixAchat, + 'quantite': p.quantite ?? 0, + 'unite': p.unite ?? '', + 'nb_contenants': p.nbContenants ?? 0, + 'stock': p.stock, + // Photo locale => envoyée en fichier (voir _fichiersImage), sinon lien web. + 'image_url': (!photoLocale && p.imageUrl != null) ? p.imageUrl : '', + }; + } + + Future> _fichiersImage(String? imageUrl) async { + if (PhotoService.estLocale(imageUrl) && await File(imageUrl!).exists()) { + return [await http.MultipartFile.fromPath('image', imageUrl)]; + } + return const []; + } + + String? _nonVide(String s) => s.isEmpty ? null : s; + + // ---- Migration du catalogue local (SQLite) vers le serveur ----------- + + /// Produits présents dans l'ancienne base locale (avant la synchro serveur). + Future> produitsLocaux() async { + try { + final db = await LocalDb.instance.database; + final rows = await db.query('produits'); + return rows.map(Produit.fromMap).toList(); + } catch (_) { + return const []; + } + } + + /// Envoie les produits locaux vers le serveur (ignore ceux déjà présents + /// via le code-barres). Retourne le nombre de produits importés. + Future importerLocaux() async { + final locaux = await produitsLocaux(); + var n = 0; + for (final p in locaux) { + if (p.codeBarres != null && parCodeBarres(p.codeBarres!) != null) { + continue; // déjà sur le serveur + } + await ajouter(p); + n++; + } + return n; + } } diff --git a/lib/services/reglages.dart b/lib/services/reglages.dart index 14c9a89..333e683 100644 --- a/lib/services/reglages.dart +++ b/lib/services/reglages.dart @@ -8,12 +8,23 @@ class Reglages { static final Reglages instance = 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'; SharedPreferences? _prefs; Future get _p async => _prefs ??= await SharedPreferences.getInstance(); + /// Adresse du serveur PocketBase (modifiable dans les réglages). + Future serveurUrl() async => + (await _p).getString(_cleServeur) ?? serveurParDefaut; + + Future setServeurUrl(String url) async => + (await _p).setString(_cleServeur, url.trim()); + /// Dernière unité choisie lors d'un ajout (pour la re-proposer par défaut). Future derniereUnite() async { final p = await _p; diff --git a/pubspec.lock b/pubspec.lock index 4ef12e0..7ecba16 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -592,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pocketbase: + dependency: "direct main" + description: + name: pocketbase + sha256: "8b30fce517205ee684b09ce253a4669965c3c714f5e3fb1ac42aac1078ef0155" + url: "https://pub.dev" + source: hosted + version: "0.24.0+1" posix: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index db68e41..29091f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: shared_preferences: ^2.5.5 image_picker: ^1.2.3 path_provider: ^2.1.6 + pocketbase: ^0.24.0+1 dev_dependencies: flutter_test: