Files
doormile_milderapp/lib/data/proof_store.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

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');
}
}
}