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>
This commit is contained in:
148
lib/data/session_store.dart
Normal file
148
lib/data/session_store.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user