Files
gestion_prix_produit/lib/services/produit_repository.dart
T
2026-07-22 12:55:43 +02:00

303 lines
9.0 KiB
Dart

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';
/// 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;
final List<Produit> _produits = [];
bool _charge = false;
bool _enCours = false;
bool _connecte = false;
String? _erreur;
String _etape = '';
List<Produit> 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;
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<void> 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
}
/// Charge les produits depuis le cache local (dernier état connu du serveur).
Future<void> _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<String, dynamic>))
.toList();
if (list.isNotEmpty) {
_produits
..clear()
..addAll(list);
_charge = true;
notifyListeners();
}
} catch (_) {
// Pas de cache : on attend le serveur.
}
}
Future<void> _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<void> changerServeur(String url) async {
await Reglages.instance.setServeurUrl(url);
await _annulerAbo?.call();
_annulerAbo = null;
_produits.clear();
_charge = false;
await initialiser();
}
Future<void> charger() async {
_enCours = true;
_erreur = null;
_setEtape('Connexion au serveur…');
try {
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) {
_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();
}
}
Future<void> _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();
}).timeout(_delaiMax);
} 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<Produit> 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<void> 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<void> 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<String, dynamic> _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<List<http.MultipartFile>> _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<List<Produit>> 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<int> 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;
}
}