Files
doormile_milderapp/lib/data/work_scope.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

203 lines
7.8 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHO A RECORD BELONGS TO
///
/// Miler runs two operations off one app and one login — a milk-man round and
/// a logistics day — and it stores finished work, skipped work, carried bags
/// and released orders in SharedPreferences. Every one of those keys was
/// **global**: `completed_bookings`, `skipped_bookings`,
/// `collected_order_ids`, `out_for_delivery_order_ids`. One phone, one key,
/// whoever wrote last.
///
/// That is three leaks in one:
///
/// • **Rider → rider.** Log out, log in as somebody else, and yesterday's
/// completed stops are sitting in the new rider's Activity.
/// • **Line → line.** A tenant switch moves the app from a round to a
/// logistics day; the milk-run's delivered lunches stayed behind in the
/// logistics history.
/// • **Tenant → tenant.** Same shape, one level up.
///
/// ── Why identity and not a label ──
///
/// The tempting fix is to filter Activity on something visible — a kitchen
/// name, the word "Milk", the tab's title. All of those are *display strings*:
/// they are localisable, they are chosen by hub staff, and two tenants can
/// legitimately use the same one. Ownership has to come from identity the
/// session actually proves:
///
/// **rider** `userid` — from the login response, held in prefs
/// **tenant** `tenantid` — from the JWT claim, signed by the server
/// **line** [ServiceLine] — the operation the profile resolves to
///
/// A [WorkScope] is those three together, and it is the only thing allowed to
/// decide which records a screen may see.
/// ─────────────────────────────────────────────────────────────────────────
@immutable
class WorkScope {
final int userId;
final int tenantId;
final ServiceLine line;
const WorkScope({
required this.userId,
required this.tenantId,
required this.line,
});
/// The scope of the session running right now.
///
/// Reads the rider from prefs and the tenant from the token's claim — the
/// same two sources the rest of the app authenticates with — and takes the
/// line from the resolved profile. Never throws: an unreadable session
/// yields the [anonymous] scope, whose records are visible to nobody but
/// itself.
static Future<WorkScope> current() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.get('userid');
final userId = raw is int
? raw
: int.tryParse(raw?.toString() ?? '') ?? 0;
final tenantId = await ApiConfig.storedTenantId();
return WorkScope(
userId: userId,
tenantId: tenantId,
line: ServiceProfile.active.line,
);
} catch (e) {
debugPrint('[SCOPE] could not resolve the session scope: $e');
return WorkScope(
userId: 0,
tenantId: 0,
line: ServiceProfile.active.line,
);
}
}
/// A signed-out or unreadable session. Deliberately still a real scope
/// rather than null: code paths that run before login write to their own
/// drawer instead of into the last rider's.
static WorkScope get anonymous =>
WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line);
/// The suffix that turns a store key into this scope's key.
///
/// `completed_bookings` → `completed_bookings::u38.t13.milkMan`
///
/// All three parts are in it because all three can change independently: a
/// rider can move tenant, a tenant can run either line, and one device can
/// see several riders.
String get key => 'u$userId.t$tenantId.${line.name}';
/// Scopes a legacy global key.
String scoped(String baseKey) => '$baseKey::$key';
/// Does [record] provably belong to this scope?
///
/// Used on rows that were written before scoping existed, and as a
/// belt-and-braces check on rows read back from a scoped key. A row proves
/// ownership by carrying the identity itself — `mileruserid` / `userid` and
/// `tenantid` are what the API stamps on assignment and consignment rows.
///
/// **Absence is not proof.** A row with no identity on it returns false: it
/// might be this rider's and it might be the last one's, and the only safe
/// reading of "might" is no.
bool owns(Map<String, dynamic> record) {
int intOf(List<String> keys) {
for (final k in keys) {
final v = record[k];
if (v == null) continue;
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n != 0) return n;
}
return 0;
}
final rowUser = intOf(const [
'mileruserid',
'MilerUserId',
'assignedmileruserid',
'userid',
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false;
if (rowUser != 0 && rowUser != userId) return false;
if (rowTenant != 0 && rowTenant != tenantId) return false;
if (rowLine.isNotEmpty && rowLine != line.name) return false;
return true;
}
/// Does [record] prove it belongs to **another** scope?
///
/// The mirror of [owns], and deliberately not its negation — they answer
/// different questions and treat silence differently:
///
/// [owns] "prove this is mine" — no identity ⇒ **false** (drop).
/// Used on legacy rows, where attributing the unattributable
/// is the leak itself.
/// [excludes] "prove this is someone
/// else's" — no identity ⇒ **false** (keep).
/// Used on rows the API just returned for the authenticated
/// session, which are already the rider's by construction and
/// mostly carry no identity of their own. Dropping those on
/// silence would empty the tab.
bool excludes(Map<String, dynamic> record) {
int intOf(List<String> keys) {
for (final k in keys) {
final v = record[k];
if (v == null) continue;
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n != 0) return n;
}
return 0;
}
final rowUser = intOf(const [
'mileruserid',
'MilerUserId',
'assignedmileruserid',
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser != 0 && userId != 0 && rowUser != userId) return true;
if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true;
if (rowLine.isNotEmpty && rowLine != line.name) return true;
return false;
}
/// Stamps [record] so a later read can prove ownership without a session.
///
/// Written at the moment a record is stored, which is the one moment the
/// scope is known for certain.
Map<String, dynamic> stamp(Map<String, dynamic> record) => {
...record,
'scopeuserid': userId,
'scopetenantid': tenantId,
'scopeline': line.name,
};
@override
bool operator ==(Object other) =>
other is WorkScope &&
other.userId == userId &&
other.tenantId == tenantId &&
other.line == line;
@override
int get hashCode => Object.hash(userId, tenantId, line);
@override
String toString() => 'WorkScope($key)';
}