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>
203 lines
7.8 KiB
Dart
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)';
|
|
}
|