Miler rider app: surface system, visible design language, backend lifecycle
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>
This commit is contained in:
160
lib/data/proof_store.dart
Normal file
160
lib/data/proof_store.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user