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>
149 lines
4.5 KiB
Dart
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;
|
|
}
|