import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/work_scope.dart'; /// ───────────────────────────────────────────────────────────────────────── /// PROOF OF DELIVERY, KEPT WHERE IT SURVIVES /// /// A photo taken at a door is evidence, and evidence that disappears is worse /// than none — it makes the rider believe he has cover he does not have. Two /// things were therefore decided deliberately: /// /// **It is copied, not referenced.** `ImagePicker` hands back a file in the /// OS *cache* directory, which Android reclaims whenever it likes and clears /// outright on a storage sweep. Pointing the Activity record at that path /// gives a record whose picture is gone by the end of the week. The file is /// copied into the app's documents directory, which is the app's to keep. /// /// **It belongs to a scope.** Filed under the same rider/tenant/line identity /// as every other record ([WorkScope]) so signing out or switching line does /// not leave one rider's doorstep photos where the next one can open them. /// /// ── The limitation this cannot solve ── /// /// **There is no upload route on the Miler API.** `POST /// /miler/consignments/:id/deliver` takes a `photourl` *string*, and nothing /// in the contract accepts a file — no multipart route, no signed-URL /// endpoint, no attachment on any other call. So the proof is real, durable /// and auditable **on the handset**, and the hub cannot see it until the /// backend grows somewhere to put it. The app deliberately does NOT send the /// local path in `photourl`: a filesystem path from somebody's phone is not a /// URL, and writing one into the delivery record would put a string in the /// hub's database that looks like evidence and resolves to nothing. /// [remoteUrlFor] is where a real URL will come from on the day that route /// exists; until then it is honestly empty. /// ───────────────────────────────────────────────────────────────────────── class ProofStore { ProofStore._(); static const String _indexKey = 'delivery_proof_paths'; /// Where the copies live, created on demand. static Future _dir() async { final base = await getApplicationDocumentsDirectory(); final dir = Directory('${base.path}/delivery_proof'); if (!await dir.exists()) await dir.create(recursive: true); return dir; } static Future _key() async => (await WorkScope.current()).scoped(_indexKey); /// Copies [sourcePath] into the app's own storage and records it against /// [orderId]. Returns the durable path, or null when the copy fails. /// /// Failure is returned rather than thrown: a rider standing at a door with a /// full disk still has to be able to complete the stop, and the delivery is /// the thing that matters. The caller decides whether to proceed without it. static Future save(String orderId, String sourcePath) async { if (orderId.trim().isEmpty || sourcePath.trim().isEmpty) return null; try { final src = File(sourcePath); if (!await src.exists()) return null; final dir = await _dir(); // The order id is the natural name — one proof per stop, and a retake // overwrites rather than accumulating. Sanitised because an id can carry // characters a filename cannot. final safe = orderId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); final dest = '${dir.path}/$safe.jpg'; await src.copy(dest); final prefs = await SharedPreferences.getInstance(); final key = await _key(); final index = [...(prefs.getStringList(key) ?? const [])] ..removeWhere((e) => e.startsWith('$orderId::')) ..add('$orderId::$dest'); await prefs.setStringList(key, index); debugPrint('[PROOF] $orderId saved to $dest'); return dest; } catch (e) { debugPrint('[PROOF] could not save proof for $orderId: $e'); return null; } } /// The stored proof for [orderId], or null when there is none **or the file /// has gone**. A path that no longer resolves is not proof, and returning it /// would draw a broken image in place of evidence. static Future pathFor(String orderId) async { if (orderId.trim().isEmpty) return null; try { final prefs = await SharedPreferences.getInstance(); final index = prefs.getStringList(await _key()) ?? const []; for (final entry in index) { final i = entry.indexOf('::'); if (i <= 0) continue; if (entry.substring(0, i) != orderId) continue; final path = entry.substring(i + 2); return await File(path).exists() ? path : null; } } catch (e) { debugPrint('[PROOF] could not read proof for $orderId: $e'); } return null; } /// The **remote** reference for [orderId], for `deliver`'s `photourl`. /// /// Always empty today: the Miler API has no route that turns a file into a /// URL. Kept as the single seam so that when one exists, the delivery call /// starts carrying a real link without any other code changing — and so /// that nobody is tempted to pass a device path in the meantime. static Future remoteUrlFor(String orderId) async => ''; /// Forgets proofs whose orders are long finished, so the directory cannot /// grow for the life of the install. Best-effort. static Future prune({int keep = 200}) async { try { final prefs = await SharedPreferences.getInstance(); final key = await _key(); final index = prefs.getStringList(key) ?? const []; if (index.length <= keep) return; final drop = index.take(index.length - keep).toList(); for (final entry in drop) { final i = entry.indexOf('::'); if (i <= 0) continue; final f = File(entry.substring(i + 2)); if (await f.exists()) await f.delete(); } await prefs.setStringList(key, index.sublist(index.length - keep)); } catch (e) { debugPrint('[PROOF] prune failed: $e'); } } /// Drops every proof in this scope. Called from logout alongside the other /// scoped stores — a doorstep photo is exactly the kind of record that must /// not outlive the session that took it. static Future clearScope() async { try { final prefs = await SharedPreferences.getInstance(); final key = await _key(); for (final entry in prefs.getStringList(key) ?? const []) { final i = entry.indexOf('::'); if (i <= 0) continue; final f = File(entry.substring(i + 2)); if (await f.exists()) await f.delete(); } await prefs.remove(key); } catch (e) { debugPrint('[PROOF] could not clear scope: $e'); } } }