Files
doormile_customer_app/lib/data/session_store.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

149 lines
4.5 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'models.dart';
/// One signed-in session: the two tokens, when the access token dies, and who
/// the customer is.
@immutable
class Session {
const Session({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
this.customer,
});
final String accessToken;
final String refreshToken;
/// Absolute expiry, computed from the `expiresIn` the server sent.
final DateTime expiresAt;
final Customer? customer;
/// Treated as expired a minute early, so a call started just before the
/// boundary does not land just after it.
bool get isExpired =>
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1)));
Session copyWith({
String? accessToken,
String? refreshToken,
DateTime? expiresAt,
Customer? customer,
}) => Session(
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expiresAt: expiresAt ?? this.expiresAt,
customer: customer ?? this.customer,
);
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresAt': expiresAt.millisecondsSinceEpoch,
if (customer != null) 'customer': customer!.toJson(),
};
static Session? fromJson(Map<String, dynamic> json) {
final access = json['accessToken'] as String?;
final refresh = json['refreshToken'] as String?;
final expires = json['expiresAt'];
if (access == null || access.isEmpty) return null;
if (refresh == null || refresh.isEmpty) return null;
return Session(
accessToken: access,
refreshToken: refresh,
expiresAt: expires is int
? DateTime.fromMillisecondsSinceEpoch(expires)
: DateTime.now(),
customer: json['customer'] is Map<String, dynamic>
? Customer.fromJson(json['customer'] as Map<String, dynamic>)
: null,
);
}
}
/// Where the session lives between launches.
///
/// The refresh token is valid for 60 days and **rotates on every use** — a
/// replayed one revokes the whole chain server-side. So the two rules this
/// class exists to enforce are: persist the newest token the moment it arrives,
/// and never hand out a half-written session.
abstract class SessionStore {
Future<Session?> read();
Future<void> write(Session session);
Future<void> clear();
}
/// Keychain on iOS, EncryptedSharedPreferences on Android.
class SecureSessionStore implements SessionStore {
SecureSessionStore({FlutterSecureStorage? storage})
// Keychain on iOS by default; Android needs asking, or it falls back to a
// plaintext preferences file — which is the one place a 60-day refresh
// token must never sit.
: _storage =
storage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final FlutterSecureStorage _storage;
static const String _key = 'dm_cx_session_v1';
@override
Future<Session?> read() async {
try {
final raw = await _storage.read(key: _key);
if (raw == null || raw.isEmpty) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return Session.fromJson(decoded);
} catch (e) {
// A session we cannot read is a session we do not have. Signing the
// customer out is recoverable; crashing at launch is not.
debugPrint('[SESSION] unreadable, treating as signed out: $e');
return null;
}
}
@override
Future<void> write(Session session) async {
try {
await _storage.write(key: _key, value: jsonEncode(session.toJson()));
} catch (e) {
// Losing persistence costs the customer a re-login next launch. It must
// not cost them the sign-in they just completed.
debugPrint('[SESSION] could not persist: $e');
}
}
@override
Future<void> clear() async {
try {
await _storage.delete(key: _key);
} catch (e) {
debugPrint('[SESSION] could not clear: $e');
}
}
}
/// In-memory store. Tests only — a session that does not survive the process
/// is not a session.
class MemorySessionStore implements SessionStore {
Session? _session;
@override
Future<Session?> read() async => _session;
@override
Future<void> write(Session session) async => _session = session;
@override
Future<void> clear() async => _session = null;
}