import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.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, 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; /// 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 records = await _pb!.collection(_collection).getFullList(sort: '-created'); _produits ..clear() ..addAll(records.map(_versProduit)); _charge = true; _connecte = true; await _sauverCache(); if (_annulerAbo == null) await _abonner(); } catch (e) { _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; } return null; } Future ajouter(Produit p) async { 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 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 { await _pb!.collection(_collection).delete(id); _produits.removeWhere((e) => e.id == id); unawaited(_sauverCache()); notifyListeners(); } // ---- 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; } }