Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
7.0 KiB
Dart
161 lines
7.0 KiB
Dart
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<Directory> _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<String> _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<String?> 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 = <String>[...(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<String?> pathFor(String orderId) async {
|
|
if (orderId.trim().isEmpty) return null;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final index = prefs.getStringList(await _key()) ?? const <String>[];
|
|
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<String> 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<void> prune({int keep = 200}) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final key = await _key();
|
|
final index = prefs.getStringList(key) ?? const <String>[];
|
|
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<void> clearScope() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final key = await _key();
|
|
for (final entry in prefs.getStringList(key) ?? const <String>[]) {
|
|
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');
|
|
}
|
|
}
|
|
}
|