(Feat) Migration PocketBase
This commit is contained in:
@@ -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<Produit> _produits = [];
|
||||
bool _charge = false;
|
||||
bool _enCours = false;
|
||||
bool _connecte = false;
|
||||
String? _erreur;
|
||||
|
||||
List<Produit> get produits => List.unmodifiable(_produits);
|
||||
bool get charge => _charge;
|
||||
bool get enCours => _enCours;
|
||||
String? get erreur => _erreur;
|
||||
|
||||
Future<Database> 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<void> 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<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;
|
||||
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<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();
|
||||
});
|
||||
} 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<Produit> 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<void> 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<void> 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user