(Feat) Migration PocketBase
This commit is contained in:
@@ -5,7 +5,8 @@
|
|||||||
<application
|
<application
|
||||||
android:label="Gestion Prix"
|
android:label="Gestion Prix"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ Future<void> main() async {
|
|||||||
DeviceOrientation.portraitDown,
|
DeviceOrientation.portraitDown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Chargement initial des produits depuis la base locale.
|
// Connexion au serveur (Raspberry Pi) + chargement + temps réel.
|
||||||
await ProduitRepository.instance.charger();
|
await ProduitRepository.instance.initialiser();
|
||||||
|
|
||||||
runApp(const MonApp());
|
runApp(const MonApp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../services/produit_repository.dart';
|
||||||
import 'etiquettes_screen.dart';
|
import 'etiquettes_screen.dart';
|
||||||
import 'produits_screen.dart';
|
import 'produits_screen.dart';
|
||||||
import 'scan_screen.dart';
|
import 'scan_screen.dart';
|
||||||
@@ -23,7 +24,19 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
const EtiquettesScreen(),
|
const EtiquettesScreen(),
|
||||||
];
|
];
|
||||||
return Scaffold(
|
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(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _index,
|
selectedIndex: _index,
|
||||||
height: 68,
|
height: 68,
|
||||||
@@ -50,3 +63,49 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ class _ProduitsScreenState extends State<ProduitsScreen> {
|
|||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: _entete(_repo.produits.length)),
|
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(
|
const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
@@ -111,6 +116,12 @@ class _ProduitsScreenState extends State<ProduitsScreen> {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.grisTexte)),
|
color: AppTheme.grisTexte)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
color: AppTheme.grisTexte,
|
||||||
|
onPressed: _ouvrirReglages,
|
||||||
|
tooltip: 'Serveur',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -177,6 +188,150 @@ class _ProduitsScreenState extends State<ProduitsScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> _ouvrirReglages() async {
|
||||||
|
final ctrl = TextEditingController(text: _repo.serveurUrl);
|
||||||
|
final locaux = await _repo.produitsLocaux();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
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<void> _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 {
|
class _ProduitCard extends StatelessWidget {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ class LocalDb {
|
|||||||
final chemin = '$dir/gestion_prix.db';
|
final chemin = '$dir/gestion_prix.db';
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
chemin,
|
chemin,
|
||||||
version: 6,
|
version: 7,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await _creerTable(db);
|
await _creerTable(db);
|
||||||
|
await _creerCache(db);
|
||||||
},
|
},
|
||||||
onUpgrade: (db, ancienne, nouvelle) async {
|
onUpgrade: (db, ancienne, nouvelle) async {
|
||||||
if (ancienne < 2) {
|
if (ancienne < 2) {
|
||||||
@@ -45,10 +46,24 @@ class LocalDb {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
'ALTER TABLE produits ADD COLUMN prix_achat REAL NOT NULL DEFAULT 0');
|
'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<void> _creerCache(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS cache_produits(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
json TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _creerTable(Database db) async {
|
Future<void> _creerTable(Database db) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE produits(
|
CREATE TABLE produits(
|
||||||
|
|||||||
@@ -1,47 +1,153 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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 '../models/produit.dart';
|
||||||
import 'local_db.dart';
|
import 'local_db.dart';
|
||||||
|
import 'photo_service.dart';
|
||||||
|
import 'reglages.dart';
|
||||||
|
|
||||||
/// Source de vérité des produits, partagée par tous les écrans.
|
/// Source de vérité des produits, synchronisée en temps réel avec le serveur
|
||||||
/// Persistée localement dans une base SQLite (aucun serveur, fonctionne hors-ligne).
|
/// PocketBase (le Raspberry Pi du magasin). Toutes les tablettes partagent
|
||||||
|
/// les mêmes données et se mettent à jour automatiquement.
|
||||||
class ProduitRepository extends ChangeNotifier {
|
class ProduitRepository extends ChangeNotifier {
|
||||||
ProduitRepository._();
|
ProduitRepository._();
|
||||||
static final ProduitRepository instance = ProduitRepository._();
|
static final ProduitRepository instance = ProduitRepository._();
|
||||||
|
|
||||||
|
static const _collection = 'produits';
|
||||||
|
|
||||||
|
PocketBase? _pb;
|
||||||
|
String _serveurUrl = '';
|
||||||
|
UnsubscribeFunc? _annulerAbo;
|
||||||
|
|
||||||
final List<Produit> _produits = [];
|
final List<Produit> _produits = [];
|
||||||
bool _charge = false;
|
bool _charge = false;
|
||||||
bool _enCours = false;
|
bool _enCours = false;
|
||||||
|
bool _connecte = false;
|
||||||
String? _erreur;
|
String? _erreur;
|
||||||
|
|
||||||
List<Produit> get produits => List.unmodifiable(_produits);
|
List<Produit> get produits => List.unmodifiable(_produits);
|
||||||
bool get charge => _charge;
|
bool get charge => _charge;
|
||||||
bool get enCours => _enCours;
|
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 {
|
Future<void> charger() async {
|
||||||
_enCours = true;
|
_enCours = true;
|
||||||
_erreur = null;
|
_erreur = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final db = await _db;
|
final records =
|
||||||
final rows = await db.query('produits', orderBy: 'nom COLLATE NOCASE ASC');
|
await _pb!.collection(_collection).getFullList(sort: '-created');
|
||||||
_produits
|
_produits
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(rows.map(Produit.fromMap));
|
..addAll(records.map(_versProduit));
|
||||||
_charge = true;
|
_charge = true;
|
||||||
|
_connecte = true;
|
||||||
|
await _sauverCache();
|
||||||
|
if (_annulerAbo == null) await _abonner();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
_enCours = false;
|
_enCours = false;
|
||||||
notifyListeners();
|
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) {
|
Produit? parCodeBarres(String code) {
|
||||||
for (final p in _produits) {
|
for (final p in _produits) {
|
||||||
if (p.codeBarres == code) return p;
|
if (p.codeBarres == code) return p;
|
||||||
@@ -50,42 +156,118 @@ class ProduitRepository extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Produit> ajouter(Produit p) async {
|
Future<Produit> ajouter(Produit p) async {
|
||||||
final db = await _db;
|
final r = await _pb!.collection(_collection).create(
|
||||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
body: _versBody(p),
|
||||||
final data = p.toInsertMap()..['cree_le'] = ts;
|
files: await _fichiersImage(p.imageUrl),
|
||||||
final id = await db.insert(
|
|
||||||
'produits',
|
|
||||||
data,
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
);
|
||||||
final cree = p.copyWith(id: id.toString(), creeLe: ts);
|
final cree = _versProduit(r);
|
||||||
_produits.add(cree);
|
_appliquer(cree);
|
||||||
_trier();
|
unawaited(_sauverCache());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return cree;
|
return cree;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> modifier(Produit p) async {
|
Future<void> modifier(Produit p) async {
|
||||||
final db = await _db;
|
final r = await _pb!.collection(_collection).update(
|
||||||
await db.update(
|
p.id,
|
||||||
'produits',
|
body: _versBody(p),
|
||||||
p.toInsertMap(),
|
files: await _fichiersImage(p.imageUrl),
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [int.tryParse(p.id) ?? p.id],
|
|
||||||
);
|
);
|
||||||
final i = _produits.indexWhere((e) => e.id == p.id);
|
_appliquer(_versProduit(r));
|
||||||
if (i != -1) _produits[i] = p;
|
unawaited(_sauverCache());
|
||||||
_trier();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> supprimer(String id) async {
|
Future<void> supprimer(String id) async {
|
||||||
final db = await _db;
|
await _pb!.collection(_collection).delete(id);
|
||||||
await db.delete('produits', where: 'id = ?', whereArgs: [int.tryParse(id) ?? id]);
|
|
||||||
_produits.removeWhere((e) => e.id == id);
|
_produits.removeWhere((e) => e.id == id);
|
||||||
|
unawaited(_sauverCache());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _trier() => _produits
|
// ---- Mapping PocketBase <-> Produit ----------------------------------
|
||||||
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,23 @@ class Reglages {
|
|||||||
static final Reglages instance = Reglages._();
|
static final Reglages instance = Reglages._();
|
||||||
|
|
||||||
static const _cleDerniereUnite = 'derniere_unite';
|
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;
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
Future<SharedPreferences> get _p async =>
|
Future<SharedPreferences> get _p async =>
|
||||||
_prefs ??= await SharedPreferences.getInstance();
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Adresse du serveur PocketBase (modifiable dans les réglages).
|
||||||
|
Future<String> serveurUrl() async =>
|
||||||
|
(await _p).getString(_cleServeur) ?? serveurParDefaut;
|
||||||
|
|
||||||
|
Future<void> 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).
|
/// Dernière unité choisie lors d'un ajout (pour la re-proposer par défaut).
|
||||||
Future<String> derniereUnite() async {
|
Future<String> derniereUnite() async {
|
||||||
final p = await _p;
|
final p = await _p;
|
||||||
|
|||||||
@@ -592,6 +592,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
image_picker: ^1.2.3
|
image_picker: ^1.2.3
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
|
pocketbase: ^0.24.0+1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user