diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5f519e3..dba713d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -55,13 +55,12 @@ android { release { // Release builds use the keystore declared in android/key.properties // (gitignored). When that file is absent (e.g. CI without secrets - // wired up), fall back to the debug keystore so the build still - // produces an installable APK -- the artifact just won't match the - // release SHA-1 registered in Firebase. - signingConfig = if (hasReleaseSigning) { - signingConfigs.getByName("release") - } else { - signingConfigs.getByName("debug") + // wired up), leave the build unsigned rather than falling back to + // the debug keystore, so a signed release artifact is never + // mistaken for a real release. CI that needs an installable APK + // builds the debug variant instead. + if (hasReleaseSigning) { + signingConfig = signingConfigs.getByName("release") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8945eab..68e1fa1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,14 @@ prompt have to stay together. Reading the photo library needs no permission on the API levels this app supports. --> + + + + = 1 + && data.roles.size() <= 4 + && data.roles.hasOnly(['viewer', 'pit', 'developer', 'admin']) + && (!('displayName' in data) + || (data.displayName is string && data.displayName.size() <= 256)) + && (!('email' in data) + || (data.email is string && data.email.size() <= 320)); + } + function isValidInventoryItem(data) { return data.keys().hasOnly(['name', 'labLocation', 'pitLocation', 'mapRef', 'status', 'updatedAt']) diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index 89c2725..b5b843a 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -2,4 +2,4 @@ You can customize the launch screen with your own desired assets by replacing the image files in this directory. -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. diff --git a/lib/main.dart b/lib/main.dart index cc012c2..65a7ab7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'src/services/map_image_store.dart'; import 'src/services/map_diagram_sync_service.dart'; import 'src/services/map_location_sync_service.dart'; import 'src/services/packing_sync_service.dart'; +import 'src/services/photo_disk_cache.dart'; import 'src/services/photo_service.dart'; import 'src/services/synced_map_image_store.dart'; import 'src/services/pit_shift_sync_service.dart'; @@ -32,6 +33,7 @@ import 'src/services/telemetry_service.dart'; import 'src/services/http_timeout_client.dart'; import 'src/services/spectrum_auth_service.dart'; import 'src/services/user_role_service.dart'; +import 'src/services/user_role_service_interface.dart'; import 'src/state/borrow_controller.dart'; import 'src/state/inventory_controller.dart'; import 'src/state/map_location_controller.dart'; @@ -158,15 +160,14 @@ Future main() async { syncService: pitShiftSyncService, ); - final photoService = PhotoService(idToken: authService.idToken); + final photoService = PhotoService( + idToken: authService.idToken, + diskCache: PhotoDiskCache(), + ); final MapImageStore mapImageStore; - if (firebaseReady && !_isDesktop) { - mapImageStore = SyncedMapImageStore( - photoService: photoService, - diagramSync: mapDiagramSyncService, - ); - } else if (_isDesktop && _oauthClientId.isNotEmpty) { + if ((firebaseReady && !_isDesktop) || + (_isDesktop && _oauthClientId.isNotEmpty)) { mapImageStore = SyncedMapImageStore( photoService: photoService, diagramSync: mapDiagramSyncService, diff --git a/lib/src/app.dart b/lib/src/app.dart index ea644ee..166cfc2 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'services/issue_report_service.dart'; @@ -51,10 +53,16 @@ class StrategyApp extends StatefulWidget { class _StrategyAppState extends State { late Future _bootstrapFuture; + StreamSubscription? _authSubscription; + + bool _wasSignedIn = false; @override void initState() { super.initState(); + _authSubscription = widget.authService.snapshotStream.listen( + _onAuthSnapshot, + ); widget.themeController.addListener(_onThemeChanged); _bootstrapFuture = _startBootstrap(); } @@ -83,6 +91,7 @@ class _StrategyAppState extends State { @override void dispose() { widget.themeController.removeListener(_onThemeChanged); + _authSubscription?.cancel(); widget.authService.dispose(); widget.themeController.dispose(); widget.userRoleController.dispose(); @@ -95,6 +104,14 @@ class _StrategyAppState extends State { super.dispose(); } + void _onAuthSnapshot(SpectrumAuthSnapshot snapshot) { + final signedIn = snapshot.state == SpectrumAuthState.signedIn; + if (_wasSignedIn && !signedIn) { + unawaited(widget.photoService.clearCache()); + } + _wasSignedIn = signedIn; + } + @override Widget build(BuildContext context) { return MaterialApp( diff --git a/lib/src/models/inventory_item.dart b/lib/src/models/inventory_item.dart index b9fd640..ab9bdfc 100644 --- a/lib/src/models/inventory_item.dart +++ b/lib/src/models/inventory_item.dart @@ -60,11 +60,13 @@ class InventoryItem implements PitModel { 'updatedAt': updatedAt.toIso8601String(), }; + static const Object _mapRefUnset = Object(); + InventoryItem copyWith({ String? name, String? labLocation, String? pitLocation, - String? mapRef, + Object? mapRef = _mapRefUnset, InventoryStatus? status, DateTime? updatedAt, }) { @@ -73,7 +75,7 @@ class InventoryItem implements PitModel { name: name ?? this.name, labLocation: labLocation ?? this.labLocation, pitLocation: pitLocation ?? this.pitLocation, - mapRef: mapRef ?? this.mapRef, + mapRef: mapRef == _mapRefUnset ? this.mapRef : mapRef as String?, status: status ?? this.status, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/lib/src/models/packing_record.dart b/lib/src/models/packing_record.dart index 854219b..7cc0845 100644 --- a/lib/src/models/packing_record.dart +++ b/lib/src/models/packing_record.dart @@ -57,13 +57,14 @@ class PackingRecord implements PitModel { String? itemId, PackingStatus? packingStatus, String? photoRef, + bool clearPhotoRef = false, DateTime? updatedAt, }) { return PackingRecord( id: id, itemId: itemId ?? this.itemId, packingStatus: packingStatus ?? this.packingStatus, - photoRef: photoRef ?? this.photoRef, + photoRef: clearPhotoRef ? null : (photoRef ?? this.photoRef), updatedAt: updatedAt ?? this.updatedAt, ); } diff --git a/lib/src/models/pit_shift.dart b/lib/src/models/pit_shift.dart index 8f1dcd0..ecb5161 100644 --- a/lib/src/models/pit_shift.dart +++ b/lib/src/models/pit_shift.dart @@ -75,8 +75,6 @@ class PitShift implements PitModel { } factory PitShift.fromJson(String id, Map data) { - final startsAtRaw = data['startsAt'] as String?; - final endsAtRaw = data['endsAt'] as String?; return PitShift( id: id, label: data['label'] as String? ?? '', @@ -86,8 +84,8 @@ class PitShift implements PitModel { assignedNames: _stringList(data['assignedNames']), startMatch: (data['startMatch'] as num?)?.toInt(), endMatch: (data['endMatch'] as num?)?.toInt(), - startsAt: startsAtRaw == null ? null : DateTime.tryParse(startsAtRaw), - endsAt: endsAtRaw == null ? null : DateTime.tryParse(endsAtRaw), + startsAt: _dateTime(data['startsAt']), + endsAt: _dateTime(data['endsAt']), notes: data['notes'] as String?, updatedAt: DateTime.tryParse(data['updatedAt'] as String? ?? '') ?? @@ -99,6 +97,12 @@ class PitShift implements PitModel { ? value.whereType().toList(growable: false) : const []; + static DateTime? _dateTime(Object? value) { + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; + } + @override Map toJson() => { 'label': label, diff --git a/lib/src/models/user_profile.dart b/lib/src/models/user_profile.dart index 937f7d8..ab00600 100644 --- a/lib/src/models/user_profile.dart +++ b/lib/src/models/user_profile.dart @@ -25,7 +25,12 @@ class UserProfile { .toSet(); roles = parsed.isEmpty ? {UserRole.viewer} : parsed; } else { - roles = {UserRole.fromString(data['role'] as String?)}; + final legacyRole = data['role']; + roles = { + legacyRole is String + ? UserRole.fromString(legacyRole) + : UserRole.viewer, + }; } return UserProfile( uid: uid, @@ -35,6 +40,13 @@ class UserProfile { ); } + static int byDisplayName(UserProfile a, UserProfile b) { + final byName = a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + return byName != 0 ? byName : a.uid.compareTo(b.uid); + } + Map toJson() => { 'uid': uid, 'displayName': displayName, diff --git a/lib/src/models/user_role.dart b/lib/src/models/user_role.dart index 08e95c9..127771f 100644 --- a/lib/src/models/user_role.dart +++ b/lib/src/models/user_role.dart @@ -35,6 +35,17 @@ enum UserRole { bool get canManageUsers => this == UserRole.admin; } +abstract final class AppTabs { + static const int inventory = 0; + static const int packing = 1; + static const int borrowed = 2; + static const int maps = 3; + static const int schedule = 4; + static const int docs = 5; + static const int users = 6; + static const int settings = 7; +} + extension UserRoleSetPermissions on Set { List get visibleTabIndices { final tabs = {}; @@ -44,9 +55,26 @@ extension UserRoleSetPermissions on Set { break; case UserRole.pit: case UserRole.developer: - tabs.addAll(const [0, 1, 2, 3, 4, 5, 7]); + tabs.addAll(const [ + AppTabs.inventory, + AppTabs.packing, + AppTabs.borrowed, + AppTabs.maps, + AppTabs.schedule, + AppTabs.docs, + AppTabs.settings, + ]); case UserRole.admin: - tabs.addAll(const [0, 1, 2, 3, 4, 5, 6, 7]); + tabs.addAll(const [ + AppTabs.inventory, + AppTabs.packing, + AppTabs.borrowed, + AppTabs.maps, + AppTabs.schedule, + AppTabs.docs, + AppTabs.users, + AppTabs.settings, + ]); } } return tabs.toList()..sort(); diff --git a/lib/src/services/desktop_auth_service.dart b/lib/src/services/desktop_auth_service.dart index b6d614a..fd5f394 100644 --- a/lib/src/services/desktop_auth_service.dart +++ b/lib/src/services/desktop_auth_service.dart @@ -58,8 +58,9 @@ class DesktopAuthService implements SpectrumAuthService { @override Future initialize() async { + SharedPreferences? prefs; try { - final prefs = await _prefsLoader(); + prefs = await _prefsLoader(); final stored = prefs.getString(_prefsKey); if (stored == null) return; final user = await _session.restore( @@ -75,7 +76,11 @@ class DesktopAuthService implements SpectrumAuthService { } else { await prefs.remove(_prefsKey); } - } catch (_) {} + } catch (_) { + try { + await prefs?.remove(_prefsKey); + } catch (_) {} + } } @override diff --git a/lib/src/services/desktop_borrow_sync_service.dart b/lib/src/services/desktop_borrow_sync_service.dart index 483b374..cb3e48c 100644 --- a/lib/src/services/desktop_borrow_sync_service.dart +++ b/lib/src/services/desktop_borrow_sync_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:flutter/foundation.dart' show debugPrint; +import 'desktop_polling.dart'; import '../models/borrow_record.dart'; import 'borrow_sync_service.dart'; @@ -9,12 +11,16 @@ class DesktopBorrowSyncService implements BorrowSyncService { DesktopBorrowSyncService({ required fc.Firestore firestore, Duration pollInterval = const Duration(seconds: 30), + void Function(Object error)? onPollError, }) : _firestore = firestore, - _pollInterval = pollInterval; + _pollInterval = pollInterval, + _onPollError = onPollError; final fc.Firestore _firestore; final Duration _pollInterval; + final void Function(Object error)? _onPollError; + @override Future> fetchAll() async { final docs = await _firestore.listDocuments('borrowRecords'); @@ -34,14 +40,22 @@ class DesktopBorrowSyncService implements BorrowSyncService { @override Stream> streamAll() async* { String? last; + var consecutiveFailures = 0; while (true) { List? items; try { final docs = await _firestore.listDocuments('borrowRecords'); items = docs.map((d) => BorrowRecord.fromJson(d.id, d.fields)).toList() ..sort((a, b) => a.id.compareTo(b.id)); - } catch (_) {} + } catch (error) { + consecutiveFailures++; + try { + _onPollError?.call(error); + } catch (_) {} + debugPrint('borrowRecords poll failed: $error'); + } if (items != null) { + consecutiveFailures = 0; final fingerprint = items .map( (i) => @@ -58,7 +72,9 @@ class DesktopBorrowSyncService implements BorrowSyncService { yield items; } } - await Future.delayed(_pollInterval); + await Future.delayed( + pollDelayFor(_pollInterval, consecutiveFailures), + ); } } } diff --git a/lib/src/services/desktop_inventory_sync_service.dart b/lib/src/services/desktop_inventory_sync_service.dart index 29f7f87..08288e4 100644 --- a/lib/src/services/desktop_inventory_sync_service.dart +++ b/lib/src/services/desktop_inventory_sync_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:flutter/foundation.dart' show debugPrint; +import 'desktop_polling.dart'; import '../models/inventory_item.dart'; import 'inventory_sync_service.dart'; @@ -9,12 +11,16 @@ class DesktopInventorySyncService implements InventorySyncService { DesktopInventorySyncService({ required fc.Firestore firestore, Duration pollInterval = const Duration(seconds: 30), + void Function(Object error)? onPollError, }) : _firestore = firestore, - _pollInterval = pollInterval; + _pollInterval = pollInterval, + _onPollError = onPollError; final fc.Firestore _firestore; final Duration _pollInterval; + final void Function(Object error)? _onPollError; + @override Future> fetchAll() async { final docs = await _firestore.listDocuments('inventoryItems'); @@ -34,14 +40,22 @@ class DesktopInventorySyncService implements InventorySyncService { @override Stream> streamAll() async* { String? last; + var consecutiveFailures = 0; while (true) { List? items; try { final docs = await _firestore.listDocuments('inventoryItems'); items = docs.map((d) => InventoryItem.fromJson(d.id, d.fields)).toList() ..sort((a, b) => a.id.compareTo(b.id)); - } catch (_) {} + } catch (error) { + consecutiveFailures++; + try { + _onPollError?.call(error); + } catch (_) {} + debugPrint('inventoryItems poll failed: $error'); + } if (items != null) { + consecutiveFailures = 0; final fingerprint = items .map( (i) => @@ -55,7 +69,9 @@ class DesktopInventorySyncService implements InventorySyncService { yield items; } } - await Future.delayed(_pollInterval); + await Future.delayed( + pollDelayFor(_pollInterval, consecutiveFailures), + ); } } } diff --git a/lib/src/services/desktop_launcher_service.dart b/lib/src/services/desktop_launcher_service.dart index 88053c2..a4dea55 100644 --- a/lib/src/services/desktop_launcher_service.dart +++ b/lib/src/services/desktop_launcher_service.dart @@ -46,11 +46,18 @@ class DesktopLauncherService { } static String desktopEntry(String appImagePath, {String? iconPath}) { + final exec = appImagePath.replaceAllMapped( + RegExp(r'["`$\\]'), + (m) => '\\${m[0]}', + ); + final icon = (iconPath ?? 'spectrumpit') + .replaceAll('\\', r'\\') + .replaceAll('\n', r'\n'); return '[Desktop Entry]\n' 'Type=Application\n' 'Name=Spectrum Pit\n' - 'Exec="$appImagePath" %U\n' - 'Icon=${iconPath ?? 'spectrumpit'}\n' + 'Exec="$exec" %U\n' + 'Icon=$icon\n' 'Categories=Utility;\n' 'Terminal=false\n'; } diff --git a/lib/src/services/desktop_map_location_sync_service.dart b/lib/src/services/desktop_map_location_sync_service.dart index df6c42e..a5a8c6b 100644 --- a/lib/src/services/desktop_map_location_sync_service.dart +++ b/lib/src/services/desktop_map_location_sync_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:flutter/foundation.dart' show debugPrint; +import 'desktop_polling.dart'; import '../models/map_location.dart'; import 'map_location_sync_service.dart'; @@ -9,12 +11,16 @@ class DesktopMapLocationSyncService implements MapLocationSyncService { DesktopMapLocationSyncService({ required fc.Firestore firestore, Duration pollInterval = const Duration(seconds: 30), + void Function(Object error)? onPollError, }) : _firestore = firestore, - _pollInterval = pollInterval; + _pollInterval = pollInterval, + _onPollError = onPollError; final fc.Firestore _firestore; final Duration _pollInterval; + final void Function(Object error)? _onPollError; + @override Future> fetchAll() async { final docs = await _firestore.listDocuments('mapLocations'); @@ -37,14 +43,22 @@ class DesktopMapLocationSyncService implements MapLocationSyncService { @override Stream> streamAll() async* { String? last; + var consecutiveFailures = 0; while (true) { List? items; try { final docs = await _firestore.listDocuments('mapLocations'); items = docs.map((d) => MapLocation.fromJson(d.id, d.fields)).toList() ..sort((a, b) => a.id.compareTo(b.id)); - } catch (_) {} + } catch (error) { + consecutiveFailures++; + try { + _onPollError?.call(error); + } catch (_) {} + debugPrint('mapLocations poll failed: $error'); + } if (items != null) { + consecutiveFailures = 0; final fingerprint = items .map( (i) => @@ -58,7 +72,9 @@ class DesktopMapLocationSyncService implements MapLocationSyncService { yield items; } } - await Future.delayed(_pollInterval); + await Future.delayed( + pollDelayFor(_pollInterval, consecutiveFailures), + ); } } } diff --git a/lib/src/services/desktop_packing_sync_service.dart b/lib/src/services/desktop_packing_sync_service.dart index 75ff2db..c50ee3b 100644 --- a/lib/src/services/desktop_packing_sync_service.dart +++ b/lib/src/services/desktop_packing_sync_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:flutter/foundation.dart' show debugPrint; +import 'desktop_polling.dart'; import '../models/packing_record.dart'; import 'packing_sync_service.dart'; @@ -9,16 +11,26 @@ class DesktopPackingSyncService implements PackingSyncService { DesktopPackingSyncService({ required fc.Firestore firestore, Duration pollInterval = const Duration(seconds: 30), + void Function(Object error)? onPollError, }) : _firestore = firestore, - _pollInterval = pollInterval; + _pollInterval = pollInterval, + _onPollError = onPollError; final fc.Firestore _firestore; final Duration _pollInterval; + final void Function(Object error)? _onPollError; + @override Future> fetchAll() async { - final docs = await _firestore.listDocuments('packingRecords'); - return docs.map((d) => PackingRecord.fromJson(d.id, d.fields)).toList(); + try { + final docs = await _firestore.listDocuments('packingRecords'); + return docs.map((d) => PackingRecord.fromJson(d.id, d.fields)).toList() + ..sort((a, b) => a.id.compareTo(b.id)); + } catch (error) { + debugPrint('packingRecords fetch failed: $error'); + return const []; + } } @override @@ -37,14 +49,22 @@ class DesktopPackingSyncService implements PackingSyncService { @override Stream> streamAll() async* { String? last; + var consecutiveFailures = 0; while (true) { List? items; try { final docs = await _firestore.listDocuments('packingRecords'); items = docs.map((d) => PackingRecord.fromJson(d.id, d.fields)).toList() ..sort((a, b) => a.id.compareTo(b.id)); - } catch (_) {} + } catch (error) { + consecutiveFailures++; + try { + _onPollError?.call(error); + } catch (_) {} + debugPrint('packingRecords poll failed: $error'); + } if (items != null) { + consecutiveFailures = 0; final fingerprint = items .map( (i) => @@ -57,7 +77,9 @@ class DesktopPackingSyncService implements PackingSyncService { yield items; } } - await Future.delayed(_pollInterval); + await Future.delayed( + pollDelayFor(_pollInterval, consecutiveFailures), + ); } } } diff --git a/lib/src/services/desktop_pit_shift_sync_service.dart b/lib/src/services/desktop_pit_shift_sync_service.dart index 9686ad2..d7d622b 100644 --- a/lib/src/services/desktop_pit_shift_sync_service.dart +++ b/lib/src/services/desktop_pit_shift_sync_service.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:flutter/foundation.dart' show debugPrint; +import 'desktop_polling.dart'; import '../models/pit_shift.dart'; import 'pit_shift_sync_service.dart'; @@ -9,12 +12,20 @@ class DesktopPitShiftSyncService implements PitShiftSyncService { DesktopPitShiftSyncService({ required fc.Firestore firestore, Duration pollInterval = const Duration(seconds: 30), + void Function(Object error)? onPollError, }) : _firestore = firestore, - _pollInterval = pollInterval; + _pollInterval = pollInterval, + _onPollError = onPollError; final fc.Firestore _firestore; final Duration _pollInterval; + final void Function(Object error)? _onPollError; + + bool _disposed = false; + + void dispose() => _disposed = true; + @override Future> fetchAll() async { final docs = await _firestore.listDocuments('pitShifts'); @@ -34,29 +45,33 @@ class DesktopPitShiftSyncService implements PitShiftSyncService { @override Stream> streamAll() async* { String? last; - while (true) { + var consecutiveFailures = 0; + while (!_disposed) { List? items; try { final docs = await _firestore.listDocuments('pitShifts'); items = docs.map((d) => PitShift.fromJson(d.id, d.fields)).toList() ..sort((a, b) => a.id.compareTo(b.id)); - } catch (_) {} + } catch (error) { + consecutiveFailures++; + try { + _onPollError?.call(error); + } catch (_) {} + debugPrint('pitShifts poll failed: $error'); + } if (items != null) { + consecutiveFailures = 0; final fingerprint = items.map(_fingerprint).join('|'); if (fingerprint != last) { last = fingerprint; yield items; } } - await Future.delayed(_pollInterval); + await Future.delayed( + pollDelayFor(_pollInterval, consecutiveFailures), + ); } } - String _fingerprint(PitShift s) => - '${s.id}:${s.label}:${s.kind.name}:${s.competition}:' - '${s.assignedUids.join(",")}:${s.assignedNames.join(",")}:' - '${s.startMatch ?? ''}:${s.endMatch ?? ''}:' - '${s.startsAt?.toIso8601String() ?? ''}:' - '${s.endsAt?.toIso8601String() ?? ''}:' - '${s.notes ?? ''}:${s.updatedAt.toIso8601String()}'; + String _fingerprint(PitShift s) => jsonEncode(s.toJson()); } diff --git a/lib/src/services/desktop_polling.dart b/lib/src/services/desktop_polling.dart new file mode 100644 index 0000000..eb86adf --- /dev/null +++ b/lib/src/services/desktop_polling.dart @@ -0,0 +1,5 @@ +Duration pollDelayFor(Duration pollInterval, int consecutiveFailures) { + if (consecutiveFailures <= 0) return pollInterval; + final multiplier = 1 << (consecutiveFailures - 1).clamp(0, 4); + return pollInterval * multiplier; +} diff --git a/lib/src/services/desktop_self_update_service.dart b/lib/src/services/desktop_self_update_service.dart index 9a7af82..94707db 100644 --- a/lib/src/services/desktop_self_update_service.dart +++ b/lib/src/services/desktop_self_update_service.dart @@ -1,15 +1,18 @@ import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:http/http.dart' as http; +import 'http_timeout_client.dart'; + class DesktopSelfUpdateService { DesktopSelfUpdateService({ http.Client? client, String? Function()? appImagePathLoader, Future Function(String path)? makeExecutable, Future Function(String path)? relaunch, - }) : _client = client ?? http.Client(), + }) : _client = client ?? TimeoutHttpClient(), _appImagePath = appImagePathLoader ?? _defaultAppImagePath, _makeExecutable = makeExecutable ?? _defaultMakeExecutable, _relaunch = relaunch ?? _defaultRelaunch; @@ -22,28 +25,53 @@ class DesktopSelfUpdateService { bool get canSelfUpdate => !kIsWeb && Platform.isLinux && (_appImagePath()?.isNotEmpty ?? false); - Future update(Uri url) async { + Future update(Uri url, {required String expectedSha256}) async { + if (url.scheme != 'https') { + throw StateError('Refusing to download a non-https update URL'); + } final path = _appImagePath(); if (path == null || path.isEmpty) { throw StateError('Not running as an AppImage'); } - final response = await _client.get(url); + final request = http.Request('GET', url)..followRedirects = false; + final streamed = await _client.send(request); + final response = await http.Response.fromStream(streamed); if (response.statusCode != 200 || response.bodyBytes.length < 100000) { throw StateError('Download failed (status ${response.statusCode})'); } + final actual = sha256.convert(response.bodyBytes).toString(); + final expected = expectedSha256.trim().toLowerCase(); + if (!_constantTimeHexEquals(actual, expected)) { + throw StateError('Downloaded update failed its checksum verification'); + } + final staged = File('$path.new'); await staged.writeAsBytes(response.bodyBytes, flush: true); + await _makeExecutable(staged.path); await staged.rename(path); - await _makeExecutable(path); await _relaunch(path); } + static bool _constantTimeHexEquals(String a, String b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diff == 0; + } + static String? _defaultAppImagePath() => Platform.environment['APPIMAGE']; static Future _defaultMakeExecutable(String path) async { - await Process.run('chmod', ['+x', path]); + final result = await Process.run('chmod', ['+x', path]); + if (result.exitCode != 0) { + throw StateError( + 'chmod +x failed (exit ${result.exitCode}): ${result.stderr}', + ); + } } static Future _defaultRelaunch(String path) async { diff --git a/lib/src/services/desktop_update_service.dart b/lib/src/services/desktop_update_service.dart index f10c5d4..e2da6aa 100644 --- a/lib/src/services/desktop_update_service.dart +++ b/lib/src/services/desktop_update_service.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'http_timeout_client.dart'; class DesktopUpdateInfo { const DesktopUpdateInfo({ @@ -10,6 +13,7 @@ class DesktopUpdateInfo { required this.releaseUrl, required this.repository, this.appImageUrl, + this.expectedSha256, }); final String currentVersion; @@ -18,6 +22,8 @@ class DesktopUpdateInfo { final String repository; final String? appImageUrl; + + final String? expectedSha256; } class DesktopUpdateService { @@ -25,12 +31,12 @@ class DesktopUpdateService { http.Client? client, Future Function()? currentVersionLoader, List? repositories, - }) : _client = client ?? http.Client(), + }) : _client = client ?? TimeoutHttpClient(), _currentVersionLoader = currentVersionLoader ?? _defaultVersionLoader, _repositories = repositories ?? _defaultRepositories; static const List _defaultRepositories = [ - 'Spectrum3847/spectrum-pit-releases', + 'Spectrum3847/spectrum-pit', ]; final http.Client _client; @@ -56,6 +62,7 @@ class DesktopUpdateService { releaseUrl: release.url, repository: repository, appImageUrl: release.appImageUrl, + expectedSha256: release.expectedSha256, ); } } @@ -63,45 +70,55 @@ class DesktopUpdateService { } Future<_ReleaseSnapshot?> _loadLatestRelease(String repository) async { - final response = await _client.get( - Uri.parse('https://api.github.com/repos/$repository/releases/latest'), - headers: const {'Accept': 'application/vnd.github+json'}, - ); - if (response.statusCode != 200) { - return null; - } - final decoded = jsonDecode(response.body); - if (decoded is! Map) { - return null; - } - final tagName = (decoded['tag_name'] as String? ?? '').trim(); - final htmlUrlRaw = (decoded['html_url'] as String? ?? '').trim(); - if (tagName.isEmpty || htmlUrlRaw.isEmpty) { - return null; - } - final version = _parseVersion(tagName); - final url = Uri.tryParse(htmlUrlRaw); - if (version == null || url == null) { + try { + final response = await _client.get( + Uri.parse('https://api.github.com/repos/$repository/releases/latest'), + headers: const {'Accept': 'application/vnd.github+json'}, + ); + if (response.statusCode != 200) { + return null; + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return null; + } + final tagName = (decoded['tag_name'] as String? ?? '').trim(); + final htmlUrlRaw = (decoded['html_url'] as String? ?? '').trim(); + if (tagName.isEmpty || htmlUrlRaw.isEmpty) { + return null; + } + final version = _parseVersion(tagName); + final url = Uri.tryParse(htmlUrlRaw); + if (version == null || url == null) { + return null; + } + final asset = _appImageAsset(decoded['assets']); + return _ReleaseSnapshot( + version: version, + rawTag: tagName, + url: url, + appImageUrl: asset.url, + expectedSha256: asset.digest, + ); + } catch (_) { return null; } - return _ReleaseSnapshot( - version: version, - rawTag: tagName, - url: url, - appImageUrl: _appImageAssetUrl(decoded['assets']), - ); } - static String? _appImageAssetUrl(dynamic assets) { - if (assets is! List) return null; + static ({String? url, String? digest}) _appImageAsset(dynamic assets) { + if (assets is! List) return (url: null, digest: null); for (final asset in assets) { if (asset is Map) { final name = asset['name'] as String? ?? ''; final dl = asset['browser_download_url'] as String? ?? ''; - if (name.endsWith('.AppImage') && dl.isNotEmpty) return dl; + if (name.endsWith('.AppImage') && dl.isNotEmpty) { + final rawDigest = asset['digest'] as String?; + final digest = rawDigest?.replaceFirst(RegExp('^sha256:'), ''); + return (url: dl, digest: digest); + } } } - return null; + return (url: null, digest: null); } static Future _defaultVersionLoader() async { @@ -116,54 +133,24 @@ class _ReleaseSnapshot { required this.rawTag, required this.url, this.appImageUrl, + this.expectedSha256, }); - final _SemanticVersion version; + final Version version; final String rawTag; final Uri url; final String? appImageUrl; + final String? expectedSha256; } -_SemanticVersion? _parseVersion(String input) { +Version? _parseVersion(String input) { final normalized = input.trim().replaceFirst(RegExp(r'^[vV]'), ''); if (normalized.isEmpty) { return null; } - final core = normalized.split(RegExp(r'[-+]')).first; - final parts = core.split('.'); - if (parts.length < 3) { - return null; - } - final major = int.tryParse(parts[0]); - final minor = int.tryParse(parts[1]); - final patch = int.tryParse(parts[2]); - if (major == null || minor == null || patch == null) { + try { + return Version.parse(normalized); + } on FormatException { return null; } - return _SemanticVersion(major: major, minor: minor, patch: patch); -} - -class _SemanticVersion implements Comparable<_SemanticVersion> { - const _SemanticVersion({ - required this.major, - required this.minor, - required this.patch, - }); - - final int major; - final int minor; - final int patch; - - @override - int compareTo(_SemanticVersion other) { - final majorDiff = major.compareTo(other.major); - if (majorDiff != 0) { - return majorDiff; - } - final minorDiff = minor.compareTo(other.minor); - if (minorDiff != 0) { - return minorDiff; - } - return patch.compareTo(other.patch); - } } diff --git a/lib/src/services/desktop_user_role_service.dart b/lib/src/services/desktop_user_role_service.dart index 36017ad..e770a4e 100644 --- a/lib/src/services/desktop_user_role_service.dart +++ b/lib/src/services/desktop_user_role_service.dart @@ -4,7 +4,7 @@ import 'package:firestore_client/firestore_client.dart' as fc; import '../models/user_profile.dart'; import '../models/user_role.dart'; -import 'user_role_service.dart'; +import 'user_role_service_interface.dart'; class DesktopUserRoleService implements UserRoleService { DesktopUserRoleService({ @@ -58,13 +58,11 @@ class DesktopUserRoleService implements UserRoleService { List? profiles; try { final docs = await _firestore.listDocuments('userProfiles'); - profiles = - docs.map((d) => UserProfile.fromJson(d.id, d.fields)).toList() - ..sort( - (a, b) => a.displayName.toLowerCase().compareTo( - b.displayName.toLowerCase(), - ), - ); + final parsed = docs + .map((d) => UserProfile.fromJson(d.id, d.fields)) + .toList(); + parsed.sort(UserProfile.byDisplayName); + profiles = parsed; } catch (_) { if (last == null) { rethrow; diff --git a/lib/src/services/local_only_services.dart b/lib/src/services/local_only_services.dart index 70d294e..57f3a01 100644 --- a/lib/src/services/local_only_services.dart +++ b/lib/src/services/local_only_services.dart @@ -3,7 +3,7 @@ import 'dart:async'; import '../models/user_profile.dart'; import '../models/user_role.dart'; import 'spectrum_auth_service.dart'; -import 'user_role_service.dart'; +import 'user_role_service_interface.dart'; class LocalOnlyAuthService implements SpectrumAuthService { LocalOnlyAuthService(); diff --git a/lib/src/services/map_image_store.dart b/lib/src/services/map_image_store.dart index 1e407ea..4250be0 100644 --- a/lib/src/services/map_image_store.dart +++ b/lib/src/services/map_image_store.dart @@ -90,15 +90,28 @@ class LocalMapImageStore implements MapImageStore { static Future _decodeSize(File file) async { final bytes = await file.readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - return Size(frame.image.width.toDouble(), frame.image.height.toDouble()); + try { + final frame = await codec.getNextFrame(); + final size = Size( + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ); + + frame.image.dispose(); + return size; + } finally { + codec.dispose(); + } } String _prefsKey(MapType mapType) => '$_prefsPrefix${mapType.name}'; + static const Set _allowedExtensions = {'png', 'jpg', 'jpeg', 'webp'}; + static String _extensionOf(String name) { final dot = name.lastIndexOf('.'); - return dot < 0 ? '.png' : name.substring(dot); + final ext = dot < 0 ? '' : name.substring(dot + 1).toLowerCase(); + return _allowedExtensions.contains(ext) ? '.$ext' : '.png'; } static Future _defaultFilePicker() { diff --git a/lib/src/services/photo_disk_cache.dart b/lib/src/services/photo_disk_cache.dart new file mode 100644 index 0000000..c2e08dd --- /dev/null +++ b/lib/src/services/photo_disk_cache.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show Uint8List, debugPrint, kIsWeb; +import 'package:path_provider/path_provider.dart'; + +class PhotoDiskCache { + PhotoDiskCache({ + Future Function()? directoryLoader, + this.maxBytes = defaultMaxBytes, + }) : _directoryLoader = directoryLoader ?? _defaultDirectory; + + static const int defaultMaxBytes = 80 * 1024 * 1024; + + final Future Function() _directoryLoader; + + final int maxBytes; + + static Future _defaultDirectory() async { + final base = await getApplicationSupportDirectory(); + return Directory('${base.path}/photo_cache'); + } + + bool get isSupported => !kIsWeb; + + Future read(String key) async { + if (!isSupported) return null; + try { + final file = await _fileFor(key); + if (!await file.exists()) return null; + final bytes = await file.readAsBytes(); + try { + await file.setLastModified(DateTime.now()); + } catch (_) {} + return bytes; + } catch (error) { + debugPrint('PhotoDiskCache read failed for $key: $error'); + return null; + } + } + + Future write(String key, Uint8List bytes) async { + if (!isSupported) return; + try { + final file = await _fileFor(key); + await file.parent.create(recursive: true); + await file.writeAsBytes(bytes, flush: true); + await _trim(); + } catch (error) { + debugPrint('PhotoDiskCache write failed for $key: $error'); + } + } + + Future remove(String key) async { + if (!isSupported) return; + try { + final file = await _fileFor(key); + if (await file.exists()) await file.delete(); + } catch (error) { + debugPrint('PhotoDiskCache remove failed for $key: $error'); + } + } + + Future clear() async { + if (!isSupported) return; + try { + final dir = await _directoryLoader(); + if (await dir.exists()) await dir.delete(recursive: true); + } catch (error) { + debugPrint('PhotoDiskCache clear failed: $error'); + } + } + + Future currentBytes() async { + if (!isSupported) return 0; + try { + final entries = await _entries(); + return entries.fold(0, (sum, e) => sum + e.size); + } catch (_) { + return 0; + } + } + + Future _fileFor(String key) async { + final dir = await _directoryLoader(); + + if (key.isEmpty || + key.contains('/') || + key.contains(r'\') || + key.contains('..')) { + throw ArgumentError.value(key, 'key', 'not a valid cache key'); + } + return File('${dir.path}/$key'); + } + + Future> _entries() async { + final dir = await _directoryLoader(); + if (!await dir.exists()) { + return const <({File file, int size, DateTime modified})>[]; + } + final result = <({File file, int size, DateTime modified})>[]; + await for (final entity in dir.list()) { + if (entity is! File) continue; + try { + final stat = await entity.stat(); + result.add((file: entity, size: stat.size, modified: stat.modified)); + } catch (_) {} + } + return result; + } + + Future _trim() async { + final entries = await _entries(); + var total = entries.fold(0, (sum, e) => sum + e.size); + if (total <= maxBytes) return; + + entries.sort((a, b) => a.modified.compareTo(b.modified)); + for (final entry in entries) { + if (total <= maxBytes) break; + try { + await entry.file.delete(); + total -= entry.size; + } catch (_) {} + } + } +} diff --git a/lib/src/services/photo_service.dart b/lib/src/services/photo_service.dart index 786fc48..e424061 100644 --- a/lib/src/services/photo_service.dart +++ b/lib/src/services/photo_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:typed_data'; @@ -9,6 +10,7 @@ import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import 'http_timeout_client.dart'; +import 'photo_disk_cache.dart'; enum PhotoSource { camera, gallery, file } @@ -35,7 +37,9 @@ class PhotoService { http.Client? httpClient, Future Function(PhotoSource source)? picker, int cacheLimit = _defaultCacheLimit, + PhotoDiskCache? diskCache, }) : _idToken = idToken, + _diskCache = diskCache, _baseUrl = baseUrl ?? Uri.parse(_defaultBaseUrl), _client = @@ -59,6 +63,8 @@ class PhotoService { final Future Function(PhotoSource source) _picker; final int _cacheLimit; + final PhotoDiskCache? _diskCache; + final LinkedHashMap _cache = LinkedHashMap(); @@ -111,6 +117,12 @@ class PhotoService { _cache[key] = cached; return cached; } + + final onDisk = await _diskCache?.read(key); + if (onDisk != null) { + _remember(key, onDisk); + return onDisk; + } final response = await _send( http.Request('GET', _baseUrl.resolve('/photos/$key')), ); @@ -118,11 +130,16 @@ class PhotoService { if (response.statusCode != 200) throw _failure('load', response); final bytes = response.bodyBytes; _remember(key, bytes); + + final disk = _diskCache; + if (disk != null) unawaited(disk.write(key, bytes)); return bytes; } Future delete(String key) async { _cache.remove(key); + + await _diskCache?.remove(key); final response = await _send( http.Request('DELETE', _baseUrl.resolve('/photos/$key')), ); @@ -134,6 +151,11 @@ class PhotoService { void close() => _client.close(); + Future clearCache() async { + _cache.clear(); + await _diskCache?.clear(); + } + Future _send(http.Request request) async { final token = await _idToken(); if (token == null || token.isEmpty) return null; diff --git a/lib/src/services/pit_shift_sync_service.dart b/lib/src/services/pit_shift_sync_service.dart index 8cc419b..1340071 100644 --- a/lib/src/services/pit_shift_sync_service.dart +++ b/lib/src/services/pit_shift_sync_service.dart @@ -24,9 +24,9 @@ class FirestorePitShiftSyncService implements PitShiftSyncService { @override Future> fetchAll() async { + final QuerySnapshot> snapshot; try { - final snapshot = await _collection.get(); - return _itemsFrom(snapshot); + snapshot = await _collection.get(); } catch (serverError) { try { final cached = await _collection.get( @@ -39,6 +39,7 @@ class FirestorePitShiftSyncService implements PitShiftSyncService { return const []; } } + return _itemsFrom(snapshot); } @override diff --git a/lib/src/services/spectrum_auth_service.dart b/lib/src/services/spectrum_auth_service.dart index 571bde7..ea73cf0 100644 --- a/lib/src/services/spectrum_auth_service.dart +++ b/lib/src/services/spectrum_auth_service.dart @@ -72,6 +72,9 @@ class FirebaseSpectrumAuthService implements SpectrumAuthService { @override Future initialize() async { + if (!kIsWeb) { + await _googleSignIn.initialize(); + } _authStateSubscription = _appAuth.authStateChanges().listen((user) { if (user == null) { if (_snapshot.state != SpectrumAuthState.signingIn) { diff --git a/lib/src/services/synced_map_image_store.dart b/lib/src/services/synced_map_image_store.dart index 1a4ce62..229b57c 100644 --- a/lib/src/services/synced_map_image_store.dart +++ b/lib/src/services/synced_map_image_store.dart @@ -85,10 +85,17 @@ class SyncedMapImageStore implements MapImageStore { key ??= (await SharedPreferences.getInstance()).getString( _prefsKey(mapType, _prefsR2Key), ); - if (key != null && key.isNotEmpty) { - await photoService.delete(key); + + var pointerCleared = false; + try { + await diagramSync.clearKey(mapType); + pointerCleared = true; + } catch (_) {} + if (pointerCleared && key != null && key.isNotEmpty) { + try { + await photoService.delete(key); + } catch (_) {} } - await diagramSync.clearKey(mapType); final prefs = await SharedPreferences.getInstance(); final file = await _cachedFile(mapType); if (file != null) { @@ -146,8 +153,18 @@ class SyncedMapImageStore implements MapImageStore { static Future _decodeSize(File file) async { final bytes = await file.readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - return Size(frame.image.width.toDouble(), frame.image.height.toDouble()); + try { + final frame = await codec.getNextFrame(); + final size = Size( + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ); + + frame.image.dispose(); + return size; + } finally { + codec.dispose(); + } } String _prefsKey(MapType mapType, String prefix) => '$prefix${mapType.name}'; diff --git a/lib/src/services/telemetry_service.dart b/lib/src/services/telemetry_service.dart index 8f13481..a89cfae 100644 --- a/lib/src/services/telemetry_service.dart +++ b/lib/src/services/telemetry_service.dart @@ -33,17 +33,33 @@ class TelemetryService { return prefs.getBool(enabledKey) ?? true; } + Future storedPreference() async { + final prefs = await _prefsLoader(); + return prefs.getBool(enabledKey); + } + Future setEnabled(bool enabled) async { final prefs = await _prefsLoader(); await prefs.setBool(enabledKey, enabled); } + Future? _deviceIdFuture; + Future _deviceId(SharedPreferences prefs) async { final existing = prefs.getString(_deviceIdKey); if (existing != null && existing.isNotEmpty) return existing; - final id = const Uuid().v4(); - await prefs.setString(_deviceIdKey, id); - return id; + return _deviceIdFuture ??= _createDeviceId(prefs); + } + + Future _createDeviceId(SharedPreferences prefs) async { + try { + final id = const Uuid().v4(); + await prefs.setString(_deviceIdKey, id); + return id; + } catch (_) { + _deviceIdFuture = null; + rethrow; + } } static String _clamp(String value, int max) => @@ -60,6 +76,7 @@ class TelemetryService { Future logEvent(String type, {String? detail}) async { try { final prefs = await _prefsLoader(); + if (!(prefs.getBool(enabledKey) ?? true)) return; final deviceId = await _deviceId(prefs); final info = await _debugInfoLoader(); diff --git a/lib/src/services/user_role_service.dart b/lib/src/services/user_role_service.dart index 03994a8..02038c6 100644 --- a/lib/src/services/user_role_service.dart +++ b/lib/src/services/user_role_service.dart @@ -2,18 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import '../models/user_profile.dart'; import '../models/user_role.dart'; - -abstract class UserRoleService { - Future> fetchOrCreateRoles({ - required String uid, - String displayName = '', - String? email, - }); - - Future updateRoles(String targetUid, Set roles); - - Stream> streamAllProfiles(); -} +import 'user_role_service_interface.dart'; class FirestoreUserRoleService implements UserRoleService { FirestoreUserRoleService({FirebaseFirestore? firestore}) @@ -62,21 +51,23 @@ class FirestoreUserRoleService implements UserRoleService { @override Future updateRoles(String targetUid, Set roles) async { - await _firestore.collection('userProfiles').doc(targetUid).update({ + await _firestore.collection('userProfiles').doc(targetUid).set({ 'roles': roles.map((r) => r.name).toList(), - }); + }, SetOptions(merge: true)); } @override Stream> streamAllProfiles() { - return _firestore - .collection('userProfiles') - .orderBy('displayName') - .snapshots() - .map( - (snapshot) => snapshot.docs - .map((doc) => UserProfile.fromJson(doc.id, doc.data())) - .toList(), - ); + return _firestore.collection('userProfiles').snapshots().map(_profilesFrom); + } + + List _profilesFrom( + QuerySnapshot> snapshot, + ) { + final profiles = snapshot.docs + .map((doc) => UserProfile.fromJson(doc.id, doc.data())) + .toList(); + profiles.sort(UserProfile.byDisplayName); + return profiles; } } diff --git a/lib/src/services/user_role_service_interface.dart b/lib/src/services/user_role_service_interface.dart new file mode 100644 index 0000000..c1f0a76 --- /dev/null +++ b/lib/src/services/user_role_service_interface.dart @@ -0,0 +1,14 @@ +import '../models/user_profile.dart'; +import '../models/user_role.dart'; + +abstract class UserRoleService { + Future> fetchOrCreateRoles({ + required String uid, + String displayName = '', + String? email, + }); + + Future updateRoles(String targetUid, Set roles); + + Stream> streamAllProfiles(); +} diff --git a/lib/src/state/pit_controller_mixin.dart b/lib/src/state/pit_controller_mixin.dart index 91d0fe7..86ac54d 100644 --- a/lib/src/state/pit_controller_mixin.dart +++ b/lib/src/state/pit_controller_mixin.dart @@ -28,34 +28,77 @@ mixin PitControllerMixin on ChangeNotifier { int _pitStreamGeneration = 0; + Future _pitCacheSaveChain = Future.value(); + List _pitItems = []; List get items => List.unmodifiable(_pitItems); Future bootstrap() { - _pitBootstrapFuture ??= _pitDoBootstrap(); - return _pitBootstrapFuture!; + return _pitBootstrapFuture ??= _pitDoBootstrap().onError(( + error, + stackTrace, + ) { + _pitBootstrapFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); } Future upsert(T item) async { + final previousIndex = _pitItems.indexWhere((e) => e.id == item.id); + final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ for (final existing in _pitItems) if (existing.id != item.id) existing, item, ]; notifyListeners(); - await _pitSaveCache(); - await pitUpsertRemote(item); + try { + await pitUpsertRemote(item); + } catch (_) { + final restored = [ + for (final existing in _pitItems) + if (existing.id != item.id) existing, + ]; + if (previousItem != null) { + restored.insert(previousIndex.clamp(0, restored.length), previousItem); + } + _pitItems = restored; + notifyListeners(); + + await _pitSaveCache().catchError((_) {}); + rethrow; + } + + await _pitSaveCache().catchError((_) {}); } Future delete(String id) async { + final previousIndex = _pitItems.indexWhere((e) => e.id == id); + final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ for (final existing in _pitItems) if (existing.id != id) existing, ]; notifyListeners(); - await _pitSaveCache(); - await pitDeleteRemote(id); + try { + await pitDeleteRemote(id); + } catch (_) { + if (previousItem != null) { + final restored = [ + for (final existing in _pitItems) + if (existing.id != id) existing, + ]; + restored.insert(previousIndex.clamp(0, restored.length), previousItem); + _pitItems = restored; + } + notifyListeners(); + + await _pitSaveCache().catchError((_) {}); + rethrow; + } + + await _pitSaveCache().catchError((_) {}); } void pitDispose() { @@ -119,11 +162,16 @@ mixin PitControllerMixin on ChangeNotifier { } } - Future _pitSaveCache() async { - final prefs = await SharedPreferences.getInstance(); + Future _pitSaveCache() { final encoded = jsonEncode([ for (final item in _pitItems) {...item.toJson(), 'id': item.id}, ]); - await prefs.setString(pitCacheKey, encoded); + final write = _pitCacheSaveChain.then((_) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(pitCacheKey, encoded); + }); + + _pitCacheSaveChain = write.catchError((_) {}); + return write; } } diff --git a/lib/src/state/theme_controller.dart b/lib/src/state/theme_controller.dart index c8d6f59..54f35ae 100644 --- a/lib/src/state/theme_controller.dart +++ b/lib/src/state/theme_controller.dart @@ -12,19 +12,29 @@ class ThemeController extends ChangeNotifier { ThemeMode get themeMode => _themeMode; Future bootstrap() { - _bootstrapFuture ??= _bootstrap(); - return _bootstrapFuture!; + return _bootstrapFuture ??= _bootstrap().onError(( + error, + stackTrace, + ) { + _bootstrapFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); } Future _bootstrap() async { - final prefs = await SharedPreferences.getInstance(); - final stored = prefs.getString(_kThemeModeKey); - - _themeMode = ThemeMode.values.firstWhere( - (mode) => mode.name == stored, - orElse: () => ThemeMode.system, - ); - notifyListeners(); + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_kThemeModeKey); + + _themeMode = ThemeMode.values.firstWhere( + (mode) => mode.name == stored, + orElse: () => ThemeMode.system, + ); + notifyListeners(); + } catch (_) { + _bootstrapFuture = null; + rethrow; + } } Future setThemeMode(ThemeMode mode) async { diff --git a/lib/src/state/user_role_controller.dart b/lib/src/state/user_role_controller.dart index 50a5782..38f50e8 100644 --- a/lib/src/state/user_role_controller.dart +++ b/lib/src/state/user_role_controller.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import '../models/user_profile.dart'; import '../models/user_role.dart'; import '../services/spectrum_auth_service.dart'; -import '../services/user_role_service.dart'; +import '../services/user_role_service_interface.dart'; class UserRoleController extends ChangeNotifier { UserRoleController({ @@ -25,10 +25,14 @@ class UserRoleController extends ChangeNotifier { int _fetchGeneration = 0; + Object? _rolesError; + Set get roles => Set.unmodifiable(_roles); String? get currentUid => _currentUid; + Object? get rolesError => _rolesError; + bool get canManageUsers => _roles.canManageUsers; bool get isDebug => _roles.isDebug; @@ -36,8 +40,13 @@ class UserRoleController extends ChangeNotifier { List get visibleTabIndices => _roles.visibleTabIndices; Future bootstrap() { - _bootstrapFuture ??= _doBootstrap(); - return _bootstrapFuture!; + return _bootstrapFuture ??= _doBootstrap().onError(( + error, + stackTrace, + ) { + _bootstrapFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); } Future _doBootstrap() async { @@ -50,19 +59,29 @@ class UserRoleController extends ChangeNotifier { final user = snapshot.user!; _currentUid = user.uid; final gen = ++_fetchGeneration; - final roles = await _roleService.fetchOrCreateRoles( - uid: user.uid, - displayName: user.displayName, - email: user.email, - ); - if (gen == _fetchGeneration) { - _roles = roles; - notifyListeners(); + try { + final roles = await _roleService.fetchOrCreateRoles( + uid: user.uid, + displayName: user.displayName, + email: user.email, + ); + if (gen == _fetchGeneration) { + _roles = roles; + _rolesError = null; + notifyListeners(); + } + } catch (error) { + if (gen == _fetchGeneration) { + _roles = {UserRole.viewer}; + _rolesError = error; + notifyListeners(); + } } } else if (snapshot.state == SpectrumAuthState.signedOut) { ++_fetchGeneration; _currentUid = null; _roles = {UserRole.viewer}; + _rolesError = null; notifyListeners(); } } diff --git a/lib/src/theme/app_theme.dart b/lib/src/theme/app_theme.dart index ea67cea..25a2c03 100644 --- a/lib/src/theme/app_theme.dart +++ b/lib/src/theme/app_theme.dart @@ -79,6 +79,8 @@ ThemeData _themeFrom({ required Color onError, }) { final isDark = brightness == Brightness.dark; + + GoogleFonts.config.allowRuntimeFetching = false; final base = isDark ? ThemeData.dark(useMaterial3: true) : ThemeData.light(useMaterial3: true); diff --git a/lib/src/theme/pit_palette.dart b/lib/src/theme/pit_palette.dart index bfaa0e3..d43ef91 100644 --- a/lib/src/theme/pit_palette.dart +++ b/lib/src/theme/pit_palette.dart @@ -58,4 +58,19 @@ class PitPalette { static Color accentOf(BuildContext context) => _isDark(context) ? violetLifted : violetDeep; + + static Color statusPackingOf(BuildContext context) => + _isDark(context) ? statusPacking : lightStatusPacking; + + static Color statusStagingOf(BuildContext context) => + _isDark(context) ? statusStaging : lightStatusStaging; + + static Color statusLoadingOf(BuildContext context) => + _isDark(context) ? statusLoading : lightStatusLoading; + + static Color statusReadyOf(BuildContext context) => + _isDark(context) ? statusReady : lightStatusReady; + + static Color statusOverdueOf(BuildContext context) => + _isDark(context) ? statusOverdue : lightStatusOverdue; } diff --git a/lib/src/ui/app_shell.dart b/lib/src/ui/app_shell.dart index 5a10bc8..1ad9328 100644 --- a/lib/src/ui/app_shell.dart +++ b/lib/src/ui/app_shell.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../services/issue_report_service.dart'; import '../services/map_image_store.dart'; @@ -15,6 +16,7 @@ import '../state/pit_shift_controller.dart'; import '../state/theme_controller.dart'; import '../state/user_role_controller.dart'; import '../theme/pit_palette.dart'; +import '../models/user_role.dart'; import 'borrow_tab.dart'; import 'docs_viewer_screen.dart'; import 'inventory_tab.dart'; @@ -59,7 +61,7 @@ class AppShell extends StatefulWidget { State createState() => _AppShellState(); } -const _kFirstSecondaryTab = 5; +const _kFirstSecondaryTab = AppTabs.docs; const _kTabMeta = [ ( @@ -137,6 +139,14 @@ class _AppShellState extends State { return i < 0 ? 0 : i; } + Object? _stepDestination(int delta) { + final count = _featureTabIndices.length; + if (count < 2) return null; + final next = (_navIndex + delta) % count; + _onNavSelected(next < 0 ? next + count : next); + return null; + } + void _onNavSelected(int navIndex) { final fullIndex = _featureTabIndices[navIndex]; if (fullIndex == _index) return; @@ -213,6 +223,32 @@ class _AppShellState extends State { } } + Widget _featureBody(int fullIndex) { + switch (fullIndex) { + case AppTabs.inventory: + return InventoryTab(controller: widget.inventoryController); + case AppTabs.packing: + return PackingTab( + controller: widget.packingController, + photoService: widget.photoService, + ); + case AppTabs.borrowed: + return BorrowTab(controller: widget.borrowController); + case AppTabs.maps: + return MapsTab( + controller: widget.mapLocationController, + inventoryController: widget.inventoryController, + imageStore: widget.mapImageStore, + ); + default: + return ScheduleTab( + controller: widget.pitShiftController, + authService: widget.authService, + roleController: widget.userRoleController, + ); + } + } + List _buildAppBarActions() { final secondary = _secondaryTabIndices; return [ @@ -326,37 +362,55 @@ class _AppShellState extends State { ); } - final tabs = [ - InventoryTab(controller: widget.inventoryController), - PackingTab( - controller: widget.packingController, - photoService: widget.photoService, - ), - BorrowTab(controller: widget.borrowController), - MapsTab( - controller: widget.mapLocationController, - inventoryController: widget.inventoryController, - imageStore: widget.mapImageStore, - ), - ScheduleTab( - controller: widget.pitShiftController, - authService: widget.authService, - roleController: widget.userRoleController, - ), - ]; + final tabs = [for (final i in features) _featureBody(i)]; - return Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: _buildTitle(context), - actions: _buildAppBarActions(), - ), - body: IndexedStack(index: _index, children: tabs), - bottomNavigationBar: NavigationBar( - selectedIndex: _navIndex, - onDestinationSelected: _onNavSelected, - destinations: _buildDestinations(), + return Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.bracketRight, control: true): + const _NextDestinationIntent(), + const SingleActivator(LogicalKeyboardKey.bracketRight, meta: true): + const _NextDestinationIntent(), + const SingleActivator(LogicalKeyboardKey.bracketLeft, control: true): + const _PreviousDestinationIntent(), + const SingleActivator(LogicalKeyboardKey.bracketLeft, meta: true): + const _PreviousDestinationIntent(), + }, + child: Actions( + actions: >{ + _NextDestinationIntent: CallbackAction<_NextDestinationIntent>( + onInvoke: (_) => _stepDestination(1), + ), + _PreviousDestinationIntent: + CallbackAction<_PreviousDestinationIntent>( + onInvoke: (_) => _stepDestination(-1), + ), + }, + + child: Focus( + autofocus: true, + child: Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: _buildTitle(context), + actions: _buildAppBarActions(), + ), + body: IndexedStack(index: _navIndex, children: tabs), + bottomNavigationBar: NavigationBar( + selectedIndex: _navIndex, + onDestinationSelected: _onNavSelected, + destinations: _buildDestinations(), + ), + ), + ), ), ); } } + +class _NextDestinationIntent extends Intent { + const _NextDestinationIntent(); +} + +class _PreviousDestinationIntent extends Intent { + const _PreviousDestinationIntent(); +} diff --git a/lib/src/ui/borrow_tab.dart b/lib/src/ui/borrow_tab.dart index 1112603..ad1ea3b 100644 --- a/lib/src/ui/borrow_tab.dart +++ b/lib/src/ui/borrow_tab.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/borrow_record.dart'; import '../state/borrow_controller.dart'; @@ -177,9 +178,7 @@ class _BorrowRow extends StatelessWidget { borderRadius: BorderRadius.circular(PitPalette.radiusSm), border: Border.all( color: overdue - ? (Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusOverdue - : PitPalette.lightStatusOverdue) + ? PitPalette.statusOverdueOf(context) : PitPalette.outlineOf(context), ), ), @@ -359,9 +358,7 @@ class _TimestampLabel extends StatelessWidget { class _ReturnedChip extends StatelessWidget { @override Widget build(BuildContext context) { - final color = Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusReady - : PitPalette.lightStatusReady; + final color = PitPalette.statusReadyOf(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -391,9 +388,7 @@ class _OverdueChip extends StatelessWidget { @override Widget build(BuildContext context) { - final color = Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusOverdue - : PitPalette.lightStatusOverdue; + final color = PitPalette.statusOverdueOf(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -502,10 +497,11 @@ class _DashedRectPainter extends CustomPainter { } String _shortDateTime(DateTime dt) { - final m = dt.month.toString().padLeft(2, '0'); - final d = dt.day.toString().padLeft(2, '0'); - final h = dt.hour.toString().padLeft(2, '0'); - final min = dt.minute.toString().padLeft(2, '0'); + final local = dt.toLocal(); + final m = local.month.toString().padLeft(2, '0'); + final d = local.day.toString().padLeft(2, '0'); + final h = local.hour.toString().padLeft(2, '0'); + final min = local.minute.toString().padLeft(2, '0'); return '$m/$d $h:$min'; } @@ -562,7 +558,7 @@ class _BorrowEditorSheetState extends State<_BorrowEditorSheet> { final existing = widget.record; widget.onSubmit( BorrowRecord( - id: existing?.id ?? 'borrow_${DateTime.now().microsecondsSinceEpoch}', + id: existing?.id ?? const Uuid().v4(), itemId: existing?.itemId, toolName: toolName, teamName: _teamName.text.trim(), @@ -579,45 +575,66 @@ class _BorrowEditorSheetState extends State<_BorrowEditorSheet> { Future _pickCheckoutDate() async { final now = DateTime.now(); + final firstDate = DateTime(now.year - 1); + final lastDate = DateTime(now.year + 1); + + final checkedOutLocal = _checkedOutAt.toLocal(); + final clamped = checkedOutLocal.isBefore(firstDate) + ? firstDate + : checkedOutLocal.isAfter(lastDate) + ? lastDate + : checkedOutLocal; final date = await showDatePicker( context: context, - initialDate: _checkedOutAt, - firstDate: DateTime(now.year - 1), - lastDate: DateTime(now.year + 1), + initialDate: clamped, + firstDate: firstDate, + lastDate: lastDate, ); if (date == null) return; if (!mounted) return; final time = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_checkedOutAt), + initialTime: TimeOfDay.fromDateTime(checkedOutLocal), ); + + if (!mounted) return; setState(() { _checkedOutAt = DateTime( date.year, date.month, date.day, - time?.hour ?? _checkedOutAt.hour, - time?.minute ?? _checkedOutAt.minute, + time?.hour ?? checkedOutLocal.hour, + time?.minute ?? checkedOutLocal.minute, ).toUtc(); }); } Future _pickEstimatedReturn() async { final now = DateTime.now(); + final firstDate = now; + final lastDate = DateTime(now.year + 1); + + final initial = (_estimatedReturn ?? now.add(const Duration(days: 1))) + .toLocal(); + final clamped = initial.isBefore(firstDate) + ? firstDate + : initial.isAfter(lastDate) + ? lastDate + : initial; final date = await showDatePicker( context: context, - initialDate: _estimatedReturn ?? now.add(const Duration(days: 1)), - firstDate: now, - lastDate: DateTime(now.year + 1), + initialDate: clamped, + firstDate: firstDate, + lastDate: lastDate, ); if (date == null) return; if (!mounted) return; + final base = _estimatedReturn ?? now.add(const Duration(hours: 2)); final time = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime( - _estimatedReturn ?? now.add(const Duration(hours: 2)), - ), + initialTime: TimeOfDay.fromDateTime(base.toLocal()), ); + if (!mounted) return; setState(() { _estimatedReturn = DateTime( date.year, diff --git a/lib/src/ui/inventory_tab.dart b/lib/src/ui/inventory_tab.dart index 7e06847..3d925ba 100644 --- a/lib/src/ui/inventory_tab.dart +++ b/lib/src/ui/inventory_tab.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/inventory_item.dart'; import '../state/inventory_controller.dart'; @@ -203,14 +204,13 @@ IconData _statusIcon(InventoryStatus status) => switch (status) { }; Color? _statusColor(BuildContext context, InventoryStatus status) { - final dark = Theme.of(context).brightness == Brightness.dark; switch (status) { case InventoryStatus.inLab: return null; case InventoryStatus.inPit: - return dark ? PitPalette.statusReady : PitPalette.lightStatusReady; + return PitPalette.statusReadyOf(context); case InventoryStatus.borrowed: - return dark ? PitPalette.statusPacking : PitPalette.lightStatusPacking; + return PitPalette.statusPackingOf(context); } } @@ -487,7 +487,7 @@ class _ItemEditorSheetState extends State<_ItemEditorSheet> { final map = _mapRef.text.trim(); widget.onSubmit( InventoryItem( - id: existing?.id ?? 'inv_${DateTime.now().microsecondsSinceEpoch}', + id: existing?.id ?? const Uuid().v4(), name: name, labLocation: _lab.text.trim(), pitLocation: _pit.text.trim(), diff --git a/lib/src/ui/maps_tab.dart b/lib/src/ui/maps_tab.dart index 6ddb76b..f8b0d6c 100644 --- a/lib/src/ui/maps_tab.dart +++ b/lib/src/ui/maps_tab.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/inventory_item.dart'; import '../models/map_location.dart'; @@ -393,6 +394,7 @@ class _MapsTabState extends State { behavior: HitTestBehavior.opaque, onTap: () => _openPinDetail(pin), onPanUpdate: (details) { + if (dest.isEmpty) return; final current = _dragPositions[pin.id] ?? Offset(pin.x, pin.y); setState(() { _dragPositions[pin.id] = Offset( @@ -651,7 +653,7 @@ class _MapPinEditorSheetState extends State<_MapPinEditorSheet> { final existing = widget.pin; widget.onSubmit( MapLocation( - id: existing?.id ?? 'map_${DateTime.now().microsecondsSinceEpoch}', + id: existing?.id ?? const Uuid().v4(), name: name, mapType: widget.mapType, x: existing?.x ?? 0.5, diff --git a/lib/src/ui/packing_photo.dart b/lib/src/ui/packing_photo.dart index b8e28d1..0222025 100644 --- a/lib/src/ui/packing_photo.dart +++ b/lib/src/ui/packing_photo.dart @@ -254,10 +254,7 @@ String _timestamp(DateTime value) { '${two(local.hour)}:${two(local.minute)}'; } -Color _overdueOf(BuildContext context) => - Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusOverdue - : PitPalette.lightStatusOverdue; +Color _overdueOf(BuildContext context) => PitPalette.statusOverdueOf(context); class _PhotoMessage extends StatelessWidget { const _PhotoMessage({ diff --git a/lib/src/ui/packing_tab.dart b/lib/src/ui/packing_tab.dart index ca1a02b..54b9fa1 100644 --- a/lib/src/ui/packing_tab.dart +++ b/lib/src/ui/packing_tab.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/packing_record.dart'; import '../services/photo_service.dart'; @@ -97,7 +98,11 @@ class _PackingTabState extends State { } finally { if (mounted) setState(() => _busy.remove(record.id)); } - if (key == null || !mounted) return; + if (key == null) return; + if (!mounted) { + await _deleteKey(key, record.itemId); + return; + } final current = _current(record); try { @@ -106,6 +111,7 @@ class _PackingTabState extends State { ); } catch (error) { _showFailure('save the photo for "${current.itemId}"', error); + await _deleteKey(key, current.itemId); return; } final previous = current.photoRef; @@ -137,12 +143,7 @@ class _PackingTabState extends State { if (key == null) return; try { await widget.controller.upsert( - PackingRecord( - id: record.id, - itemId: record.itemId, - packingStatus: record.packingStatus, - updatedAt: DateTime.now().toUtc(), - ), + record.copyWith(clearPhotoRef: true, updatedAt: DateTime.now().toUtc()), ); } catch (error) { _showFailure('remove the photo from "${record.itemId}"', error); @@ -176,14 +177,14 @@ class _PackingTabState extends State { builder: (sheetContext) => _RecordEditorSheet( record: record, photoService: widget.photoService, - onSubmit: (result) { - widget.controller - .upsert(result) - .catchError( - (Object error) => - _showFailure('save "${result.itemId}"', error), - ); - Navigator.of(sheetContext).pop(); + onSubmit: (result) async { + try { + await widget.controller.upsert(result); + return true; + } catch (error) { + _showFailure('save "${result.itemId}"', error); + return false; + } }, onDelete: record == null ? null @@ -193,15 +194,17 @@ class _PackingTabState extends State { record.itemId, ); if (!confirmed) return; - await widget.controller - .delete(record.id) - .catchError( - (Object error) => - _showFailure('delete "${record.itemId}"', error), - ); - - final photoRef = record.photoRef; - if (photoRef != null) { + var deleted = false; + + final photoRef = _current(record).photoRef; + try { + await widget.controller.delete(record.id); + deleted = true; + } catch (error) { + _showFailure('delete "${record.itemId}"', error); + } + + if (deleted && photoRef != null) { await _deleteKey(photoRef, record.itemId); } if (sheetContext.mounted) Navigator.of(sheetContext).pop(); @@ -259,16 +262,11 @@ IconData _statusIcon(PackingStatus status) => switch (status) { }; Color _statusColor(BuildContext context, PackingStatus status) { - final dark = Theme.of(context).brightness == Brightness.dark; return switch (status) { - PackingStatus.packing => - dark ? PitPalette.statusPacking : PitPalette.lightStatusPacking, - PackingStatus.staging => - dark ? PitPalette.statusStaging : PitPalette.lightStatusStaging, - PackingStatus.loading => - dark ? PitPalette.statusLoading : PitPalette.lightStatusLoading, - PackingStatus.ready => - dark ? PitPalette.statusReady : PitPalette.lightStatusReady, + PackingStatus.packing => PitPalette.statusPackingOf(context), + PackingStatus.staging => PitPalette.statusStagingOf(context), + PackingStatus.loading => PitPalette.statusLoadingOf(context), + PackingStatus.ready => PitPalette.statusReadyOf(context), }; } @@ -345,37 +343,41 @@ class _StatusChip extends StatelessWidget { final color = _statusColor(context, status); final fg = color; final bg = color.withValues(alpha: 0.2); - return Material( - color: bg, - borderRadius: BorderRadius.circular(PitPalette.radiusSm), - child: InkWell( - onTap: onTap, + + return Tooltip( + message: '${_statusLabel(status)}. Tap to advance the packing stage.', + child: Material( + color: bg, borderRadius: BorderRadius.circular(PitPalette.radiusSm), - child: Container( - constraints: const BoxConstraints(minHeight: 40), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(PitPalette.radiusSm), - border: Border.all(color: color), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_statusIcon(status), size: 16, color: fg), - const SizedBox(width: 6), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - _statusLabel(status), - softWrap: false, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: fg), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + child: Container( + constraints: const BoxConstraints(minHeight: 40), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + border: Border.all(color: color), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_statusIcon(status), size: 16, color: fg), + const SizedBox(width: 6), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + _statusLabel(status), + softWrap: false, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: fg), + ), ), ), - ), - ], + ], + ), ), ), ), @@ -534,9 +536,7 @@ class _PhotoSlotState extends State<_PhotoSlot> { Icon( Icons.error_outline_rounded, size: glyph, - color: Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusOverdue - : PitPalette.lightStatusOverdue, + color: PitPalette.statusOverdueOf(context), ), 'Photo did not load. Tap to try again.', ); @@ -738,7 +738,8 @@ class _RecordEditorSheet extends StatefulWidget { final PackingRecord? record; final PhotoService photoService; - final ValueChanged onSubmit; + + final Future Function(PackingRecord record) onSubmit; final Future Function()? onDelete; @override @@ -753,7 +754,13 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { String? _photoRef; - bool _photoIsCaptured = false; + late final String? _originalPhotoRef; + + final List _capturedKeys = []; + + bool _saved = false; + + bool _saving = false; bool _busy = false; @override @@ -763,11 +770,18 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { _itemId = TextEditingController(text: record?.itemId ?? ''); _status = record?.packingStatus ?? PackingStatus.packing; _photoRef = record?.photoRef; + _originalPhotoRef = record?.photoRef; } @override void dispose() { _itemId.dispose(); + + if (!_saved && !_saving) { + for (final key in _capturedKeys) { + widget.photoService.delete(key).catchError((_) {}); + } + } super.dispose(); } @@ -787,20 +801,17 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { } finally { if (mounted) setState(() => _busy = false); } - if (key == null || !mounted) return; - final previous = _photoRef; - final previousWasCaptured = _photoIsCaptured; + if (key == null) return; + if (!mounted) { + await widget.photoService.delete(key).catchError((_) {}); + return; + } + + final captured = key; setState(() { - _photoRef = key; - _photoIsCaptured = true; + _photoRef = captured; + _capturedKeys.add(captured); }); - if (previousWasCaptured && previous != null) { - try { - await widget.photoService.delete(previous); - } catch (error) { - _showFailure('delete the previous photo', error); - } - } } void _showFailure(String action, Object error) { @@ -810,19 +821,44 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { ).showSnackBar(SnackBar(content: Text('Could not $action: $error'))); } - void _save() { + Future _save() async { final itemId = _itemId.text.trim(); if (itemId.isEmpty) return; + if (_saving) return; + setState(() => _saving = true); final existing = widget.record; - widget.onSubmit( - PackingRecord( - id: existing?.id ?? 'pack_${DateTime.now().microsecondsSinceEpoch}', - itemId: itemId, - packingStatus: _status, - photoRef: _photoRef, - updatedAt: DateTime.now().toUtc(), - ), - ); + bool committed; + try { + committed = await widget.onSubmit( + PackingRecord( + id: existing?.id ?? const Uuid().v4(), + itemId: itemId, + packingStatus: _status, + photoRef: _photoRef, + updatedAt: DateTime.now().toUtc(), + ), + ); + } finally { + _saving = false; + } + if (!mounted || !committed) return; + _saved = true; + + final claimed = _photoRef; + for (final key in _capturedKeys) { + if (key != claimed) { + try { + await widget.photoService.delete(key); + } catch (_) {} + } + } + final original = _originalPhotoRef; + if (original != null && original != claimed) { + try { + await widget.photoService.delete(original); + } catch (_) {} + } + if (mounted) Navigator.of(context).pop(); } @override diff --git a/lib/src/ui/schedule_tab.dart b/lib/src/ui/schedule_tab.dart index 5765153..5282b6e 100644 --- a/lib/src/ui/schedule_tab.dart +++ b/lib/src/ui/schedule_tab.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/pit_shift.dart'; import '../models/user_profile.dart'; @@ -70,12 +71,6 @@ class _ScheduleTabState extends State { final conflicts = widget.controller.conflicts .where((c) => c.first.competition == competition) .toList(); - final conflictIds = { - for (final conflict in conflicts) ...[ - conflict.first.id, - conflict.second.id, - ], - }; final rows = _mineOnly ? shifts @@ -86,6 +81,13 @@ class _ScheduleTabState extends State { ? conflicts.where((c) => uid != null && _involves(c, uid)).toList() : conflicts; + final conflictIds = { + for (final conflict in shownConflicts) ...[ + conflict.first.id, + conflict.second.id, + ], + }; + return Column( children: [ _ScheduleHeader( @@ -793,11 +795,31 @@ class _ShiftEditorSheetState extends State<_ShiftEditorSheet> { _RangeMode.time => _startsAt != null || _endsAt != null, }; + bool get _rangeValid => switch (_mode) { + _RangeMode.match => _matchRangeValid, + _RangeMode.time => _timeRangeValid, + }; + + bool get _matchRangeValid { + final start = int.tryParse(_startMatch.text.trim()); + final end = int.tryParse(_endMatch.text.trim()); + if (start == null || end == null) return true; + return start <= end; + } + + bool get _timeRangeValid { + final start = _startsAt; + final end = _endsAt; + if (start == null || end == null) return true; + return !start.isAfter(end); + } + bool get _canSave => _label.text.trim().isNotEmpty && _competition.text.trim().isNotEmpty && _selected.isNotEmpty && - _rangeSet; + _rangeSet && + _rangeValid; void _save() { if (!_canSave) return; @@ -806,7 +828,7 @@ class _ShiftEditorSheetState extends State<_ShiftEditorSheet> { final uids = _selected.keys.toList(growable: false); widget.onSubmit( PitShift( - id: existing?.id ?? 'shift_${DateTime.now().microsecondsSinceEpoch}', + id: existing?.id ?? const Uuid().v4(), label: _label.text.trim(), kind: _kind, competition: _competition.text.trim(), @@ -1166,24 +1188,16 @@ IconData _kindIcon(ShiftKind kind) => switch (kind) { }; Color _kindColor(BuildContext context, ShiftKind kind) { - final dark = Theme.of(context).brightness == Brightness.dark; return switch (kind) { - ShiftKind.loadIn => - dark ? PitPalette.statusStaging : PitPalette.lightStatusStaging, - ShiftKind.matchBlock => - dark ? PitPalette.statusReady : PitPalette.lightStatusReady, - ShiftKind.pitDuty => - dark ? PitPalette.statusLoading : PitPalette.lightStatusLoading, - ShiftKind.loadOut => - dark ? PitPalette.statusPacking : PitPalette.lightStatusPacking, + ShiftKind.loadIn => PitPalette.statusStagingOf(context), + ShiftKind.matchBlock => PitPalette.statusReadyOf(context), + ShiftKind.pitDuty => PitPalette.statusLoadingOf(context), + ShiftKind.loadOut => PitPalette.statusPackingOf(context), ShiftKind.unavailable => PitPalette.inkMutedOf(context), }; } -Color _overdueOf(BuildContext context) => - Theme.of(context).brightness == Brightness.dark - ? PitPalette.statusOverdue - : PitPalette.lightStatusOverdue; +Color _overdueOf(BuildContext context) => PitPalette.statusOverdueOf(context); String _rangeLabel(PitShift shift) { if (shift.hasMatchRange) { diff --git a/lib/src/ui/settings_tab.dart b/lib/src/ui/settings_tab.dart index 487ca2b..1489273 100644 --- a/lib/src/ui/settings_tab.dart +++ b/lib/src/ui/settings_tab.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; + show debugPrint, defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:url_launcher/url_launcher.dart'; @@ -132,7 +132,11 @@ class _SettingsTabState extends State { ); _showReportSnack('Report sent. Thank you.'); } catch (e) { - _showReportSnack('Could not send the report: $e', isError: true); + debugPrint('Bug report submit failed: $e'); + _showReportSnack( + 'Could not send the report. Please try again.', + isError: true, + ); } } @@ -368,7 +372,9 @@ class _DesktopUpdateTileState extends State<_DesktopUpdateTile> { DesktopUpdateInfo? _update; bool get _canInstall => - _update?.appImageUrl != null && _selfUpdate.canSelfUpdate; + _update?.appImageUrl != null && + _update?.expectedSha256 != null && + _selfUpdate.canSelfUpdate; Future _check() async { setState(() { @@ -396,19 +402,26 @@ class _DesktopUpdateTileState extends State<_DesktopUpdateTile> { Future _openDownload() async { final info = _update; if (info == null) return; - await launchUrl(info.releaseUrl, mode: LaunchMode.externalApplication); + final launched = await launchUrl( + info.releaseUrl, + mode: LaunchMode.externalApplication, + ); + if (!launched && mounted) { + setState(() => _status = 'Could not open the download page.'); + } } Future _install() async { final info = _update; final url = info?.appImageUrl; - if (url == null) return; + final digest = info?.expectedSha256; + if (url == null || digest == null) return; setState(() { _installing = true; _status = 'Downloading update...'; }); try { - await _selfUpdate.update(Uri.parse(url)); + await _selfUpdate.update(Uri.parse(url), expectedSha256: digest); } catch (_) { if (!mounted) return; setState(() { @@ -489,18 +502,34 @@ class _TelemetryTile extends StatefulWidget { class _TelemetryTileState extends State<_TelemetryTile> { late final TelemetryService _service = widget.service ?? TelemetryService(); bool _enabled = true; + bool _busy = false; @override void initState() { super.initState(); - _service.isEnabled().then((value) { - if (mounted) setState(() => _enabled = value); - }); + _service + .isEnabled() + .then((value) { + if (mounted) setState(() => _enabled = value); + }) + .catchError((Object error) { + debugPrint('Telemetry preference read failed: $error'); + }); } Future _toggle(bool value) async { + if (_busy) return; + _busy = true; + final previous = _enabled; setState(() => _enabled = value); - await _service.setEnabled(value); + try { + await _service.setEnabled(value); + } catch (error) { + debugPrint('Telemetry preference write failed: $error'); + if (mounted) setState(() => _enabled = previous); + } finally { + _busy = false; + } } @override diff --git a/lib/src/ui/user_management_screen.dart b/lib/src/ui/user_management_screen.dart index 7ccb75b..d71fc31 100644 --- a/lib/src/ui/user_management_screen.dart +++ b/lib/src/ui/user_management_screen.dart @@ -126,6 +126,8 @@ class _UserProfileTileState extends State<_UserProfileTile> { setState(() => _saving = true); try { await widget.onRolesChanged!(_pendingRoles); + + if (mounted) setState(() => _expanded = false); } catch (e) { if (mounted) { ScaffoldMessenger.of( @@ -133,12 +135,7 @@ class _UserProfileTileState extends State<_UserProfileTile> { ).showSnackBar(SnackBar(content: Text('Failed to save roles: $e'))); } } finally { - if (mounted) { - setState(() { - _saving = false; - _expanded = false; - }); - } + if (mounted) setState(() => _saving = false); } } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 15388f2..2183e16 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "spectrumpit"); + gtk_header_bar_set_title(header_bar, "Spectrum Pit"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "spectrumpit"); + gtk_window_set_title(window, "Spectrum Pit"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..8165abf 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,9 @@ com.apple.security.network.server + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..60bd7da 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -4,6 +4,8 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spectrum Pit CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..741903e 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + diff --git a/pubspec.lock b/pubspec.lock index dd1176e..97875bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -114,7 +114,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -309,7 +309,7 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.1.0" + ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b resolved-ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b url: "https://github.com/Project516/firestore_client.git" source: git @@ -730,7 +730,7 @@ packages: source: hosted version: "2.1.8" pub_semver: - dependency: transitive + dependency: "direct main" description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" diff --git a/pubspec.yaml b/pubspec.yaml index 3fd0d32..3230535 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+3 +version: 1.1.0+4 environment: sdk: ^3.11.5 @@ -43,7 +43,7 @@ dependencies: firestore_client: git: url: https://github.com/Project516/firestore_client.git - ref: v0.1.0 + ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b shared_preferences: ^2.5.5 uuid: ^4.6.0 package_info_plus: ^10.2.1 @@ -63,6 +63,8 @@ dependencies: # dependency. Desktop and web stay on file_selector, since neither has a # camera to shoot with. image_picker: ^1.2.3 + pub_semver: ^2.1.4 + crypto: ^3.0.6 dev_dependencies: flutter_test: @@ -76,3 +78,16 @@ flutter: uses-material-design: true assets: - docs/ + fonts: + - family: IBM Plex Sans + fonts: + - asset: assets/fonts/IBMPlexSans-Regular.ttf + weight: 400 + - asset: assets/fonts/IBMPlexSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/IBMPlexSans-Bold.ttf + weight: 700 + - family: IBM Plex Mono + fonts: + - asset: assets/fonts/IBMPlexMono-Medium.ttf + weight: 500 diff --git a/test/borrow_tab_test.dart b/test/borrow_tab_test.dart index 082332e..0ed3f7d 100644 --- a/test/borrow_tab_test.dart +++ b/test/borrow_tab_test.dart @@ -35,11 +35,13 @@ Future _makeController({ List initial = const [], }) async { SharedPreferences.setMockInitialValues({}); - final sync = FakeBorrowSyncService(); + sync = FakeBorrowSyncService(); final controller = BorrowController( authService: FakeSpectrumAuthService(initialUser: _user), syncService: sync, ); + // Cleanup runs even when an expectation fails later. + addTearDown(controller.dispose); await controller.bootstrap(); if (initial.isNotEmpty) sync.emit(initial); return controller; @@ -49,6 +51,8 @@ Widget _wrap(BorrowController controller) => MaterialApp( home: Scaffold(body: BorrowTab(controller: controller)), ); +late FakeBorrowSyncService sync; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -61,7 +65,6 @@ void main() { expect(find.text('No tools on loan'), findsOneWidget); expect(find.text('Check out tool'), findsWidgets); - controller.dispose(); }); testWidgets('items are displayed with tool name and team', (tester) async { @@ -73,7 +76,6 @@ void main() { expect(find.text('Drill'), findsOneWidget); expect(find.text('254'), findsOneWidget); - controller.dispose(); }); testWidgets('active loan shows Check in button', (tester) async { @@ -84,7 +86,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Check in'), findsOneWidget); - controller.dispose(); }); testWidgets('returned loan shows Returned chip', (tester) async { @@ -96,7 +97,6 @@ void main() { expect(find.text('Returned'), findsOneWidget); expect(find.text('Check in'), findsNothing); - controller.dispose(); }); testWidgets('tapping Check in marks the record as returned', (tester) async { @@ -111,7 +111,7 @@ void main() { expect(controller.items.single.returned, isTrue); expect(controller.items.single.checkedInAt, isNotNull); - controller.dispose(); + expect(sync.upserts, isNotEmpty); }); testWidgets('FAB opens the checkout editor', (tester) async { @@ -125,7 +125,6 @@ void main() { expect(find.text('Check out tool'), findsWidgets); // Should have text fields for tool name, team name, team number, competition expect(find.byType(TextField), findsNWidgets(4)); - controller.dispose(); }); testWidgets('checkout editor creates a new record', (tester) async { @@ -149,7 +148,7 @@ void main() { expect(controller.items.length, 1); expect(controller.items.single.toolName, 'Wrench'); expect(controller.items.single.teamNumber, 254); - controller.dispose(); + expect(sync.upserts, isNotEmpty); }); testWidgets('tapping a row opens the edit editor', (tester) async { @@ -164,7 +163,6 @@ void main() { expect(find.text('Edit loan'), findsOneWidget); expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); - controller.dispose(); }); testWidgets('delete button removes the record', (tester) async { @@ -180,12 +178,13 @@ void main() { await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); - // Confirm dialog - await tester.tap(find.text('Delete')); + // Confirm dialog: the Delete action is the dialog's FilledButton, not the + // sheet's delete icon (whose tooltip is also 'Delete'). + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); expect(controller.items, isEmpty); - controller.dispose(); + expect(sync.deletes, ['a']); }); testWidgets('overdue loan shows Overdue chip', (tester) async { @@ -203,7 +202,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Overdue'), findsOneWidget); - controller.dispose(); }); testWidgets('active loans sort above returned ones', (tester) async { @@ -226,6 +224,5 @@ void main() { final activeBox = tester.getCenter(activeFinder); final returnedBox = tester.getCenter(returnedFinder); expect(activeBox.dy, lessThan(returnedBox.dy)); - controller.dispose(); }); } diff --git a/test/dark_theme_tokens_test.dart b/test/dark_theme_tokens_test.dart index 4936b14..f342b1f 100644 --- a/test/dark_theme_tokens_test.dart +++ b/test/dark_theme_tokens_test.dart @@ -55,7 +55,7 @@ void main() { expect(PitPalette.accentOf(context), PitPalette.violetDeep); }); - testWidgets('dark theme wires the Shadow Board tokens', (tester) async { + test('dark theme wires the Shadow Board tokens', () { final theme = buildDarkAppTheme(); expect(theme.colorScheme.primary, PitPalette.violetCore); expect(theme.scaffoldBackgroundColor, PitPalette.caseBlack); @@ -66,83 +66,166 @@ void main() { }); // Guards for the drawn-depth and one-violet rules that a token swap could - // silently regress (each of these caught a real gap in review). + // silently regress (each of these caught a real gap in review). Pure theme + // assertions run over both theme builders. group('Shadow Board token guards', () { - testWidgets( - 'sheets and dialogs draw Surface Strong plus the outline border', - (tester) async { - final dark = buildDarkAppTheme(); - expect( - dark.bottomSheetTheme.backgroundColor, - PitPalette.caseSurfaceStrong, - ); - expect(dark.dialogTheme.backgroundColor, PitPalette.caseSurfaceStrong); - final sheetShape = - dark.bottomSheetTheme.shape as RoundedRectangleBorder; - expect(sheetShape.side.color, PitPalette.outline); - expect(dark.bottomSheetTheme.elevation, 0); - - final light = buildAppTheme(); - expect( - light.bottomSheetTheme.backgroundColor, - PitPalette.lightSurfaceStrong, - ); - final lightSheetShape = - light.bottomSheetTheme.shape as RoundedRectangleBorder; - expect(lightSheetShape.side.color, PitPalette.lightOutline); - }, - ); - - testWidgets('secondary buttons carry Ink labels, not the violet accent', ( - tester, - ) async { - final theme = buildDarkAppTheme(); - final outlined = theme.outlinedButtonTheme.style!.foregroundColor! - .resolve({}); - final text = theme.textButtonTheme.style!.foregroundColor!.resolve( - {}, + test('sheets and dialogs draw Surface Strong plus the outline border', () { + final dark = buildDarkAppTheme(); + expect( + dark.bottomSheetTheme.backgroundColor, + PitPalette.caseSurfaceStrong, ); - expect(outlined, PitPalette.ink); - expect(text, PitPalette.ink); - expect(outlined, isNot(PitPalette.violetCore)); - }); + expect(dark.dialogTheme.backgroundColor, PitPalette.caseSurfaceStrong); + final sheetShape = dark.bottomSheetTheme.shape as RoundedRectangleBorder; + expect(sheetShape.side.color, PitPalette.outline); + expect(dark.bottomSheetTheme.elevation, 0); - testWidgets('buttons are at least 48 tall', (tester) async { - final theme = buildDarkAppTheme(); - final size = theme.filledButtonTheme.style!.minimumSize!.resolve( - {}, + final light = buildAppTheme(); + expect( + light.bottomSheetTheme.backgroundColor, + PitPalette.lightSurfaceStrong, ); - expect(size!.height, 48); + final lightSheetShape = + light.bottomSheetTheme.shape as RoundedRectangleBorder; + expect(lightSheetShape.side.color, PitPalette.lightOutline); }); - testWidgets('type scale is capped at the 18px ceiling', (tester) async { - final t = buildDarkAppTheme().textTheme; - for (final style in [ - t.displayLarge, - t.displayMedium, - t.displaySmall, - t.headlineLarge, - t.headlineMedium, - t.headlineSmall, - t.titleLarge, + test('secondary buttons carry Ink labels, not the violet accent', () { + for (final (theme, ink) in [ + (buildDarkAppTheme(), PitPalette.ink), + (buildAppTheme(), PitPalette.lightInk), ]) { - expect(style!.fontSize, lessThanOrEqualTo(18)); + final outlined = theme.outlinedButtonTheme.style!.foregroundColor! + .resolve({}); + final text = theme.textButtonTheme.style!.foregroundColor!.resolve( + {}, + ); + expect(outlined, ink); + expect(text, ink); + expect(outlined, isNot(PitPalette.violetCore)); } }); - testWidgets('input focus is a 2px violet border', (tester) async { - final theme = buildDarkAppTheme(); - final focused = - theme.inputDecorationTheme.focusedBorder as OutlineInputBorder; - expect(focused.borderSide.width, 2); - expect(focused.borderSide.color, PitPalette.violetCore); + test('buttons are at least 48 tall', () { + for (final theme in [buildDarkAppTheme(), buildAppTheme()]) { + final size = theme.filledButtonTheme.style!.minimumSize!.resolve( + {}, + ); + expect(size!.height, 48); + } }); - testWidgets('no Material surface tint leaks through the color scheme', ( - tester, - ) async { + test('type scale is capped at the 18px ceiling', () { + for (final theme in [buildDarkAppTheme(), buildAppTheme()]) { + final t = theme.textTheme; + for (final style in [ + t.displayLarge, + t.displayMedium, + t.displaySmall, + t.headlineLarge, + t.headlineMedium, + t.headlineSmall, + t.titleLarge, + ]) { + expect(style!.fontSize, lessThanOrEqualTo(18)); + } + } + }); + + test('input focus is a 2px violet border', () { + for (final (theme, accent) in [ + (buildDarkAppTheme(), PitPalette.violetCore), + (buildAppTheme(), PitPalette.violetDeep), + ]) { + final focused = + theme.inputDecorationTheme.focusedBorder as OutlineInputBorder; + expect(focused.borderSide.width, 2); + expect(focused.borderSide.color, accent); + } + }); + + test('no Material surface tint leaks through the color scheme', () { expect(buildDarkAppTheme().colorScheme.surfaceTint, Colors.transparent); expect(buildAppTheme().colorScheme.surfaceTint, Colors.transparent); }); }); + + // #169: the status accessors had no coverage in either brightness. They colour + // every shift chip and inventory badge, so a raw token leaking across themes + // shows up as an unreadable chip rather than as a crash. + group('status tokens resolve per brightness', () { + Future pump(WidgetTester tester, ThemeData theme) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (c) { + context = c; + return const SizedBox.shrink(); + }, + ), + ), + ); + return context; + } + + testWidgets('dark mode resolves the dark status tokens', (tester) async { + final context = await pump(tester, buildDarkAppTheme()); + + expect(PitPalette.statusPackingOf(context), PitPalette.statusPacking); + expect(PitPalette.statusStagingOf(context), PitPalette.statusStaging); + expect(PitPalette.statusLoadingOf(context), PitPalette.statusLoading); + expect(PitPalette.statusReadyOf(context), PitPalette.statusReady); + expect(PitPalette.statusOverdueOf(context), PitPalette.statusOverdue); + }); + + testWidgets('light mode resolves the light status tokens', (tester) async { + final context = await pump(tester, buildAppTheme()); + + expect( + PitPalette.statusPackingOf(context), + PitPalette.lightStatusPacking, + ); + expect( + PitPalette.statusStagingOf(context), + PitPalette.lightStatusStaging, + ); + expect( + PitPalette.statusLoadingOf(context), + PitPalette.lightStatusLoading, + ); + expect(PitPalette.statusReadyOf(context), PitPalette.lightStatusReady); + expect( + PitPalette.statusOverdueOf(context), + PitPalette.lightStatusOverdue, + ); + }); + + test('no status token is shared across the two themes', () { + // Compared as constants rather than through two pumps: reading them from + // captured BuildContexts across a theme swap resolved the wrong theme and + // made this pass or fail for reasons unrelated to the palette. + const dark = [ + PitPalette.statusPacking, + PitPalette.statusStaging, + PitPalette.statusLoading, + PitPalette.statusReady, + PitPalette.statusOverdue, + ]; + const light = [ + PitPalette.lightStatusPacking, + PitPalette.lightStatusStaging, + PitPalette.lightStatusLoading, + PitPalette.lightStatusReady, + PitPalette.lightStatusOverdue, + ]; + + // A per-theme token that does not differ would make its accessor + // pointless. + for (var i = 0; i < dark.length; i++) { + expect(dark[i], isNot(light[i]), reason: 'index $i'); + } + }); + }); } diff --git a/test/desktop_auth_service_test.dart b/test/desktop_auth_service_test.dart index a8f4f8a..9b805d6 100644 --- a/test/desktop_auth_service_test.dart +++ b/test/desktop_auth_service_test.dart @@ -41,7 +41,7 @@ MockClient _firebaseBackend({int refreshStatus = 200}) { } DesktopAuthService _service({int refreshStatus = 200}) { - return DesktopAuthService( + final service = DesktopAuthService( clientId: 'client-123', firebaseApiKey: 'fake-key', session: fc.FirebaseAuthSession( @@ -50,6 +50,8 @@ DesktopAuthService _service({int refreshStatus = 200}) { ), signInFlow: () async => const fc.GoogleTokens(idToken: 'google-id-token'), ); + addTearDown(service.dispose); + return service; } void main() { @@ -67,6 +69,29 @@ void main() { expect(await service.idToken(), 'fb-token-1'); }); + test( + 'snapshotStream emits signingIn then signedIn on a successful sign-in', + () async { + final service = _service(); + final states = []; + final sub = service.snapshotStream.listen((s) => states.add(s.state)); + addTearDown(sub.cancel); + + await service.signIn(); + // The broadcast stream delivers asynchronously; flush the queue so the + // signedIn emission has reached the listener before asserting. + await Future.delayed(Duration.zero); + + expect( + states, + containsAllInOrder([ + SpectrumAuthState.signingIn, + SpectrumAuthState.signedIn, + ]), + ); + }, + ); + test('signIn persists the session for the next launch', () async { await _service().signIn(); @@ -132,6 +157,7 @@ void main() { signInFlow: () async => throw StateError('Sign-in was cancelled or denied.'), ); + addTearDown(service.dispose); await service.signIn(); expect(service.snapshot.state, SpectrumAuthState.error); diff --git a/test/desktop_launcher_service_test.dart b/test/desktop_launcher_service_test.dart index a7baa39..0edd839 100644 --- a/test/desktop_launcher_service_test.dart +++ b/test/desktop_launcher_service_test.dart @@ -11,8 +11,19 @@ void main() { expect(entry, contains('Exec="/home/u/App x.AppImage" %U')); }); + test('isSupported needs a non-empty AppImage path on Linux', () { + final empty = DesktopLauncherService(appImagePathLoader: () => ''); + final set = DesktopLauncherService( + appImagePathLoader: () => '/tmp/App.AppImage', + ); + expect(empty.isSupported, isFalse); + // The result depends on the host: only a real Linux host reports support. + expect(set.isSupported, Platform.isLinux); + }); + test('registerInLauncher writes the entry under the home dir', () async { final dir = Directory.systemTemp.createTempSync('launcher'); + addTearDown(() => dir.deleteSync(recursive: true)); final service = DesktopLauncherService( appImagePathLoader: () => '/tmp/App.AppImage', appDirLoader: () => null, @@ -26,7 +37,6 @@ void main() { expect(entry, contains('Exec="/tmp/App.AppImage"')); // No AppDir means no icon to install; fall back to the theme name. expect(entry, contains('Icon=spectrumpit\n')); - dir.deleteSync(recursive: true); }); test( @@ -34,6 +44,8 @@ void main() { () async { final home = Directory.systemTemp.createTempSync('launcher-home'); final appDir = Directory.systemTemp.createTempSync('launcher-appdir'); + addTearDown(() => home.deleteSync(recursive: true)); + addTearDown(() => appDir.deleteSync(recursive: true)); File( '${appDir.path}/spectrumpit.png', ).writeAsBytesSync(List.filled(200, 0x42)); @@ -48,14 +60,14 @@ void main() { final icon = '${home.path}/.local/share/icons/spectrumpit.png'; expect(File(icon).existsSync(), isTrue); expect(File(path).readAsStringSync(), contains('Icon=$icon\n')); - home.deleteSync(recursive: true); - appDir.deleteSync(recursive: true); }, ); test('registerInLauncher skips the placeholder stub icon', () async { final home = Directory.systemTemp.createTempSync('launcher-home'); final appDir = Directory.systemTemp.createTempSync('launcher-appdir'); + addTearDown(() => home.deleteSync(recursive: true)); + addTearDown(() => appDir.deleteSync(recursive: true)); // release-desktop.yml writes an 8-byte PNG header when no icon ships. File( '${appDir.path}/spectrumpit.png', @@ -69,8 +81,6 @@ void main() { final path = await service.registerInLauncher(); expect(File(path).readAsStringSync(), contains('Icon=spectrumpit\n')); - home.deleteSync(recursive: true); - appDir.deleteSync(recursive: true); }); test('registerInLauncher throws without an AppImage path', () async { diff --git a/test/desktop_pit_shift_sync_service_test.dart b/test/desktop_pit_shift_sync_service_test.dart index 57bbbd0..f64182a 100644 --- a/test/desktop_pit_shift_sync_service_test.dart +++ b/test/desktop_pit_shift_sync_service_test.dart @@ -188,5 +188,41 @@ void main() { expect(results.first.single.assignedUids, ['uid-1']); expect(results.last.single.assignedUids, ['uid-1', 'uid-2']); }); + + test('an unchanged schedule emits exactly once across polls', () async { + final service = DesktopPitShiftSyncService( + pollInterval: const Duration(milliseconds: 5), + firestore: _firestore( + MockClient( + (_) async => http.Response( + jsonEncode({ + 'documents': [ + jsonDecode(_doc('pitShifts', 'a', _shiftFields())), + ], + }), + 200, + ), + ), + ), + ); + + // Registered before the subscription so the loop stops even if an + // expectation below throws (#169). + addTearDown(service.dispose); + final emissions = >[]; + final sub = service.streamAll().listen(emissions.add); + await Future.delayed(const Duration(milliseconds: 40)); + // dispose() first: an async* generator only acts on cancellation at a + // yield, and this one stops yielding once the fingerprint stabilizes, so + // cancel() alone never completes (CI timed out proving it). Stopping the + // loop lets it return, and then the await is safe. + service.dispose(); + await sub.cancel(); + + // Many polls run in that window, but the fingerprint match suppresses + // every repeat: one emission proves the schedule is stable. + expect(emissions.length, 1); + expect(emissions.single.single.id, 'a'); + }); }); } diff --git a/test/desktop_self_update_service_test.dart b/test/desktop_self_update_service_test.dart index 4b3dc31..78734e2 100644 --- a/test/desktop_self_update_service_test.dart +++ b/test/desktop_self_update_service_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -8,6 +9,7 @@ import 'package:spectrumpit/src/services/desktop_self_update_service.dart'; void main() { test('update swaps the AppImage and relaunches', () async { final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); final target = File('${dir.path}/App.AppImage')..writeAsBytesSync([0]); final payload = List.filled(200000, 66); var madeExec = ''; @@ -19,16 +21,43 @@ void main() { relaunch: (p) async => relaunched = p, ); - await service.update(Uri.parse('https://example.com/App.AppImage')); + await service.update( + Uri.parse('https://example.com/App.AppImage'), + expectedSha256: sha256.convert(payload).toString(), + ); expect(target.readAsBytesSync(), payload); - expect(madeExec, target.path); + // chmod runs on the staged file, before the rename, so a chmod failure + // cannot leave the installed AppImage non-executable. + expect(madeExec, '${target.path}.new'); expect(relaunched, target.path); - dir.deleteSync(recursive: true); + }); + + test('update accepts an uppercase or padded digest', () async { + // A digest read out of release metadata can arrive uppercase or with + // surrounding whitespace. Neither is a checksum mismatch. + final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); + final target = File('${dir.path}/App.AppImage')..writeAsBytesSync([0]); + final payload = List.filled(200000, 66); + final service = DesktopSelfUpdateService( + client: MockClient((_) async => http.Response.bytes(payload, 200)), + appImagePathLoader: () => target.path, + makeExecutable: (_) async {}, + relaunch: (_) async {}, + ); + + await service.update( + Uri.parse('https://example.com/App.AppImage'), + expectedSha256: ' ${sha256.convert(payload).toString().toUpperCase()}\n', + ); + + expect(target.readAsBytesSync(), payload); }); test('update throws on a too-small download', () async { final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); final target = File('${dir.path}/App.AppImage')..writeAsBytesSync([0]); final service = DesktopSelfUpdateService( client: MockClient((_) async => http.Response('not found', 200)), @@ -38,10 +67,79 @@ void main() { ); await expectLater( - service.update(Uri.parse('https://example.com/x')), + service.update( + Uri.parse('https://example.com/x'), + expectedSha256: '0' * 64, + ), + throwsStateError, + ); + }); + + test( + 'update throws on a non-200 response and leaves the target unchanged', + () async { + final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); + final target = File('${dir.path}/App.AppImage') + ..writeAsBytesSync([1, 2, 3]); + final payload = List.filled(200000, 77); + final service = DesktopSelfUpdateService( + client: MockClient((_) async => http.Response.bytes(payload, 500)), + appImagePathLoader: () => target.path, + makeExecutable: (_) async {}, + relaunch: (_) async {}, + ); + + await expectLater( + service.update( + Uri.parse('https://example.com/x'), + expectedSha256: '0' * 64, + ), + throwsStateError, + ); + // The existing file is untouched; the error body is never swapped in. + expect(target.readAsBytesSync(), [1, 2, 3]); + }, + ); + + test('update rejects a non-https URL', () async { + final service = DesktopSelfUpdateService( + client: MockClient((_) async => http.Response.bytes([], 200)), + appImagePathLoader: () => '/tmp/App.AppImage', + makeExecutable: (_) async {}, + relaunch: (_) async {}, + ); + + await expectLater( + service.update( + Uri.parse('http://example.com/x'), + expectedSha256: '0' * 64, + ), + throwsStateError, + ); + }); + + test('update verifies a supplied checksum and aborts on mismatch', () async { + final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); + final target = File('${dir.path}/App.AppImage') + ..writeAsBytesSync([1, 2, 3]); + final payload = List.filled(200000, 88); + final service = DesktopSelfUpdateService( + client: MockClient((_) async => http.Response.bytes(payload, 200)), + appImagePathLoader: () => target.path, + makeExecutable: (_) async {}, + relaunch: (_) async {}, + ); + + await expectLater( + service.update( + Uri.parse('https://example.com/App.AppImage'), + expectedSha256: '0' * 64, + ), throwsStateError, ); - dir.deleteSync(recursive: true); + expect(target.readAsBytesSync(), [1, 2, 3]); }); test('update throws when not running as an AppImage', () async { @@ -53,8 +151,42 @@ void main() { ); await expectLater( - service.update(Uri.parse('https://example.com/x')), + service.update( + Uri.parse('https://example.com/x'), + expectedSha256: '0' * 64, + ), throwsStateError, ); }); + + test( + 'update rejects a redirect response without touching the target', + () async { + final dir = Directory.systemTemp.createTempSync('selfupdate'); + addTearDown(() => dir.deleteSync(recursive: true)); + final target = File('${dir.path}/App.AppImage') + ..writeAsBytesSync([1, 2, 3]); + final service = DesktopSelfUpdateService( + client: MockClient( + (_) async => http.Response( + 'moved', + 302, + headers: {'location': 'http://insecure.example.com/App.AppImage'}, + ), + ), + appImagePathLoader: () => target.path, + makeExecutable: (_) async {}, + relaunch: (_) async {}, + ); + + await expectLater( + service.update( + Uri.parse('https://example.com/App.AppImage'), + expectedSha256: '0' * 64, + ), + throwsStateError, + ); + expect(target.readAsBytesSync(), [1, 2, 3]); + }, + ); } diff --git a/test/desktop_update_service_test.dart b/test/desktop_update_service_test.dart index 79b0bf9..8003ca4 100644 --- a/test/desktop_update_service_test.dart +++ b/test/desktop_update_service_test.dart @@ -88,4 +88,38 @@ void main() { expect(info, isNotNull); expect(info!.appImageUrl, 'https://example.com/app.AppImage'); }); + + test( + 'keeps checking the fallback repository when the primary has nothing', + () async { + final requested = []; + final client = MockClient((request) async { + final segments = request.url.pathSegments; + final repo = '${segments[1]}/${segments[2]}'; + requested.add(repo); + // The primary has no newer release; the fallback does. + final tag = segments[2] == 'primary' ? 'v1.0.0' : 'v2.0.0'; + return http.Response( + jsonEncode({ + 'tag_name': tag, + 'html_url': 'https://example.com/releases/$tag', + }), + 200, + ); + }); + final service = DesktopUpdateService( + client: client, + currentVersionLoader: () async => '1.5.0', + repositories: const ['owner/primary', 'owner/fallback'], + ); + + final info = await service.checkForUpdate(); + + // A null (no-newer) result from the primary does not end the search. + expect(requested, ['owner/primary', 'owner/fallback']); + expect(info, isNotNull); + expect(info!.latestVersion, 'v2.0.0'); + expect(info.repository, 'owner/fallback'); + }, + ); } diff --git a/test/docs_assets_exist_test.dart b/test/docs_assets_exist_test.dart index 585e6fb..3e8fb7b 100644 --- a/test/docs_assets_exist_test.dart +++ b/test/docs_assets_exist_test.dart @@ -7,9 +7,10 @@ import 'package:flutter_test/flutter_test.dart'; /// point at a real bundled file, so a renamed or removed manual can't leave a /// dead in-app link. Runs from the package root under `flutter test`. void main() { - test('every docs/*.md asset referenced in lib/ exists', () { + test('every docs/*.md asset referenced in lib/ exists and is bundled', () { final referenced = {}; - final pattern = RegExp(r"'(docs/[\w./-]+\.md)'"); + // Matches both single- and double-quoted doc literals; group 1 is the path. + final pattern = RegExp("['\"](docs/[\\w./-]+\\.md)['\"]"); for (final entity in Directory('lib').listSync(recursive: true)) { if (entity is! File || !entity.path.endsWith('.dart')) continue; for (final match in pattern.allMatches(entity.readAsStringSync())) { @@ -29,5 +30,37 @@ void main() { isEmpty, reason: 'These docs are referenced in lib/ but do not exist: $missing', ); + + // Every referenced doc must also be covered by the asset configuration + // declared in pubspec.yaml, or the in-app reader cannot load it. + final declared = []; + var inAssets = false; + for (final line in File('pubspec.yaml').readAsLinesSync()) { + final trimmed = line.trim(); + if (trimmed == 'assets:') { + inAssets = true; + } else if (inAssets) { + if (trimmed.startsWith('- ')) { + declared.add(trimmed.substring(2).trim()); + } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { + inAssets = false; + } + } + } + final unbundled = + referenced + .where( + (p) => + !declared.any((asset) => p == asset || p.startsWith(asset)), + ) + .toList() + ..sort(); + expect( + unbundled, + isEmpty, + reason: + 'These referenced docs are not covered by the pubspec ' + 'flutter.assets configuration: $unbundled', + ); }); } diff --git a/test/docs_viewer_test.dart b/test/docs_viewer_test.dart index f6b5407..85dbed6 100644 --- a/test/docs_viewer_test.dart +++ b/test/docs_viewer_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spectrumpit/src/models/user_role.dart'; import 'package:spectrumpit/src/ui/docs_viewer_screen.dart'; @@ -64,7 +65,9 @@ void main() { // doc title shows in the app bar. expect(find.text('Start here'), findsNothing); expect(find.widgetWithText(AppBar, 'Overview'), findsOneWidget); - // The asset finished loading (not stuck on the spinner). + // The asset finished loading: no spinner, and the Markdown actually + // rendered (not stuck on a loading or error state). expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(Markdown), findsOneWidget); }); } diff --git a/test/inventory_tab_test.dart b/test/inventory_tab_test.dart index dff977e..3a5d3e1 100644 --- a/test/inventory_tab_test.dart +++ b/test/inventory_tab_test.dart @@ -38,8 +38,6 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - tearDown(() => controller.dispose()); - // Builds a signed-in, bootstrapped controller and (optionally) seeds it via a // realtime emission, then pumps the tab under the app theme on a tall surface // so the list builds every row. @@ -53,6 +51,8 @@ void main() { authService: FakeSpectrumAuthService(initialUser: _signedInUser), syncService: sync, ); + // Cleanup runs only when initialization reached the assignment. + addTearDown(controller.dispose); await controller.bootstrap(); if (seed.isNotEmpty) sync.emit(seed); @@ -189,6 +189,19 @@ void main() { expect(saved.labLocation, 'RC3'); expect(saved.pitLocation, 'CAB-C1'); expect(saved.status, InventoryStatus.inLab); - expect(saved.id, startsWith('inv_')); + // New records get a UUID v4, never a timestamp-derived id (#161). Matched on + // shape rather than merely non-empty, which 'new-item' would also satisfy + // (#169). + expect(saved.id, isNot(startsWith('inv_'))); + expect( + saved.id, + matches( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-' + r'[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ), + ), + ); }); } diff --git a/test/issue_report_service_test.dart b/test/issue_report_service_test.dart index 297dc0b..f91f01d 100644 --- a/test/issue_report_service_test.dart +++ b/test/issue_report_service_test.dart @@ -62,8 +62,23 @@ void main() { final data = (await firestore.collection('bugReports').get()).docs.single .data(); expect(data['roles'], 'Admin, Pit'); - // Still only whitelisted keys, now including roles. - expect(data.keys.contains('roles'), isTrue); + // Still exactly the whitelisted keys, now including roles (mirrors the + // key-set comparison in the first test; firestore.rules isValidBugReport + // is the source of truth for this set). + expect(data.keys.toSet(), { + 'id', + 'title', + 'body', + 'reporterUid', + 'reporterName', + 'appVersion', + 'platform', + 'osVersion', + 'deviceInfo', + 'status', + 'createdAt', + 'roles', + }); }); test('submit omits roles when none are granted', () async { diff --git a/test/local_only_services_test.dart b/test/local_only_services_test.dart index f5a17d2..29c5f12 100644 --- a/test/local_only_services_test.dart +++ b/test/local_only_services_test.dart @@ -42,6 +42,13 @@ void main() { expect(controller.roles, {UserRole.pit}); expect(controller.visibleTabIndices, isNotEmpty); expect(controller.canManageUsers, isFalse); + + // Local-only grants pit, never admin, so role management is unavailable + // (user management needs Firestore, which is absent offline). + await expectLater( + controller.updateUserRoles('local', {UserRole.admin}), + throwsStateError, + ); }, ); } diff --git a/test/map_location_controller_test.dart b/test/map_location_controller_test.dart index 8cf8ac3..06e1658 100644 --- a/test/map_location_controller_test.dart +++ b/test/map_location_controller_test.dart @@ -1,14 +1,13 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spectrumpit/src/models/map_location.dart'; -import 'package:spectrumpit/src/services/map_location_sync_service.dart'; import 'package:spectrumpit/src/services/spectrum_auth_service.dart'; import 'package:spectrumpit/src/state/map_location_controller.dart'; +import 'support/fake_map_location_sync_service.dart'; import 'support/fake_spectrum_auth_service.dart'; MapLocation _loc( @@ -43,7 +42,7 @@ void main() { }); final controller = MapLocationController( authService: FakeSpectrumAuthService(), - syncService: _FakeSyncService(), + syncService: FakeMapLocationSyncService(), ); await controller.bootstrap(); @@ -54,7 +53,7 @@ void main() { }); test('signed-in stream emission replaces items', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(initialUser: _signedInUser), syncService: sync, @@ -69,7 +68,7 @@ void main() { }); test('upsert adds optimistically and persists', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(), syncService: sync, @@ -84,7 +83,7 @@ void main() { }); test('delete removes optimistically', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(), syncService: sync, @@ -101,7 +100,7 @@ void main() { }); test('locationsForMap filters by map type', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(), syncService: sync, @@ -120,7 +119,7 @@ void main() { 'stale-stream guard: emit after sign-out does not change items', () async { final auth = FakeSpectrumAuthService(initialUser: _signedInUser); - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: auth, syncService: sync, @@ -143,7 +142,7 @@ void main() { ); test('bootstrap is idempotent', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(initialUser: _signedInUser), syncService: sync, @@ -158,7 +157,7 @@ void main() { }); test('a stream error keeps the last items and does not crash', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(initialUser: _signedInUser), syncService: sync, @@ -192,7 +191,7 @@ void main() { }); test('disposing while bootstrap is in flight does not throw', () async { - final sync = _FakeSyncService(); + final sync = FakeMapLocationSyncService(); final controller = MapLocationController( authService: FakeSpectrumAuthService(initialUser: _signedInUser), syncService: sync, @@ -205,33 +204,3 @@ void main() { await expectLater(booting, completes); }); } - -class _FakeSyncService implements MapLocationSyncService { - final Map _items = {}; - final _controller = StreamController>.broadcast(); - - final List upserts = []; - final List deletes = []; - - void emit(List items) => _controller.add(items); - - void emitError(Object error) => _controller.addError(error); - - @override - Future> fetchAll() async => _items.values.toList(); - - @override - Future upsert(MapLocation location) async { - upserts.add(location); - _items[location.id] = location; - } - - @override - Future delete(String id) async { - deletes.add(id); - _items.remove(id); - } - - @override - Stream> streamAll() => _controller.stream; -} diff --git a/test/map_location_test.dart b/test/map_location_test.dart index 8f5915b..e267797 100644 --- a/test/map_location_test.dart +++ b/test/map_location_test.dart @@ -27,6 +27,19 @@ void main() { expect(loc.x, 0); expect(loc.y, 0); expect(loc.inventoryItemId, isNull); + // Missing updatedAt falls back to the Unix epoch in UTC. + expect( + loc.updatedAt, + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); + }); + + test('an unparseable updatedAt falls back to the Unix epoch', () { + final loc = MapLocation.fromJson('x', {'updatedAt': 'not-a-timestamp'}); + expect( + loc.updatedAt, + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); }); }); @@ -48,6 +61,8 @@ void main() { expect(restored.x, original.x); expect(restored.y, original.y); expect(restored.inventoryItemId, original.inventoryItemId); + // updatedAt survives the roundtrip unchanged. + expect(restored.updatedAt, original.updatedAt); }); test('omits null inventoryItemId', () { diff --git a/test/maps_tab_test.dart b/test/maps_tab_test.dart index 1f6c0a2..e406924 100644 --- a/test/maps_tab_test.dart +++ b/test/maps_tab_test.dart @@ -84,11 +84,6 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - tearDown(() { - mapController.dispose(); - inventoryController.dispose(); - }); - Future pumpTab( WidgetTester tester, { List pins = const [], @@ -108,6 +103,9 @@ void main() { authService: auth, syncService: inventorySync, ); + // Cleanup runs only when the controller was actually constructed. + addTearDown(mapController.dispose); + addTearDown(inventoryController.dispose); await mapController.bootstrap(); await inventoryController.bootstrap(); if (pins.isNotEmpty) mapSync.emit(pins); @@ -344,10 +342,16 @@ void main() { await tester.tap(find.text('Pit')); await tester.pumpAndSettle(); expect(find.text('No pit diagram set'), findsNothing); + // Store-level: the pending lab removal has not been applied yet. + expect(imageStore.images[MapType.lab], isNotNull); gate.complete(); await tester.pumpAndSettle(); expect(find.text('No pit diagram set'), findsNothing); + // The lab removal applied once the gate settled; the pit diagram is + // untouched by a removal that was started for the lab map. + expect(imageStore.images[MapType.lab], isNull); + expect(imageStore.images[MapType.pit], isNotNull); }); } diff --git a/test/packing_controller_test.dart b/test/packing_controller_test.dart index 0a176d9..2bc5704 100644 --- a/test/packing_controller_test.dart +++ b/test/packing_controller_test.dart @@ -142,11 +142,15 @@ void main() { ); await Future.wait([controller.bootstrap(), controller.bootstrap()]); + var notified = 0; + controller.addListener(() => notified++); sync.emit([_item('x')]); await Future.delayed(Duration.zero); - // A double subscription would still land a consistent single list. + // A double subscription would still land a consistent single list, and + // the single emission produces exactly one notification. expect(controller.items.map((i) => i.id), ['x']); + expect(notified, 1); controller.dispose(); }); diff --git a/test/packing_tab_test.dart b/test/packing_tab_test.dart index c7f222c..2e0977d 100644 --- a/test/packing_tab_test.dart +++ b/test/packing_tab_test.dart @@ -33,11 +33,13 @@ Future _makeController({ List initial = const [], }) async { SharedPreferences.setMockInitialValues({}); - final sync = FakePackingSyncService(); + sync = FakePackingSyncService(); final controller = PackingController( authService: FakeSpectrumAuthService(initialUser: _user), syncService: sync, ); + // Cleanup runs even when an expectation fails later. + addTearDown(controller.dispose); await controller.bootstrap(); if (initial.isNotEmpty) sync.emit(initial); return controller; @@ -55,6 +57,8 @@ Widget _wrap(PackingController controller, {PhotoService? photoService}) => ), ); +late FakePackingSyncService sync; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -67,7 +71,6 @@ void main() { expect(find.text('The packing list is empty'), findsOneWidget); expect(find.text('Add item'), findsWidgets); - controller.dispose(); }); testWidgets('items are displayed in a list', (tester) async { @@ -82,7 +85,6 @@ void main() { expect(find.text('Drill Kit'), findsOneWidget); expect(find.text('Soldering Iron'), findsOneWidget); - controller.dispose(); }); testWidgets('status chip shows the correct label', (tester) async { @@ -93,7 +95,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Staging'), findsOneWidget); - controller.dispose(); }); testWidgets('tapping status chip advances the pipeline', (tester) async { @@ -109,7 +110,7 @@ void main() { expect(controller.items.single.packingStatus, PackingStatus.staging); expect(find.text('Staging'), findsOneWidget); - controller.dispose(); + expect(sync.upserts, isNotEmpty); }); testWidgets('FAB opens the add editor', (tester) async { @@ -122,7 +123,6 @@ void main() { expect(find.text('Add packing item'), findsOneWidget); expect(find.byType(TextField), findsOneWidget); - controller.dispose(); }); testWidgets('add editor creates a new record', (tester) async { @@ -141,7 +141,6 @@ void main() { expect(controller.items.length, 1); expect(controller.items.single.itemId, 'Wrench Set'); - controller.dispose(); }); testWidgets('add button is disabled when name is empty', (tester) async { @@ -157,7 +156,6 @@ void main() { ); // The save button should be disabled when the text field is empty. expect(button.onPressed, isNull); - controller.dispose(); }); testWidgets('tapping a row opens the edit editor', (tester) async { @@ -172,7 +170,6 @@ void main() { expect(find.text('Edit packing item'), findsOneWidget); expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); - controller.dispose(); }); testWidgets('a record with no photo shows the capture affordance', ( @@ -184,7 +181,6 @@ void main() { expect(find.byIcon(Icons.add_a_photo_outlined), findsOneWidget); expect(find.byTooltip('Add a packing photo'), findsOneWidget); - controller.dispose(); }); testWidgets('an attached photo renders as a thumbnail', (tester) async { @@ -202,7 +198,6 @@ void main() { expect(find.byType(Image), findsOneWidget); expect(find.byIcon(Icons.add_a_photo_outlined), findsNothing); expect(find.byTooltip('Open the packing photo'), findsOneWidget); - controller.dispose(); }); testWidgets('a photo that will not load offers a retry', (tester) async { @@ -224,7 +219,6 @@ void main() { find.byTooltip('Photo did not load. Tap to try again.'), findsOneWidget, ); - controller.dispose(); }); testWidgets('photos degrade to an unavailable slot with no ID token', ( @@ -237,36 +231,37 @@ void main() { await tester.pumpAndSettle(); expect(find.byIcon(Icons.cloud_off_rounded), findsOneWidget); - controller.dispose(); }); testWidgets('tapping the empty slot captures and attaches the photo', ( tester, ) async { // Desktop offers one capture source, so the tap goes straight to the - // picker with no source sheet in between. + // picker with no source sheet in between. The override is reset in the + // finally block so a failing expectation still clears it. debugDefaultTargetPlatformOverride = TargetPlatform.linux; - final controller = await _makeController(initial: [_record('a')]); - await tester.pumpWidget( - _wrap( - controller, - photoService: fakePhotoService( - picker: (_) async => - PickedPhoto(bytes: tinyPng, contentType: 'image/png'), + try { + final controller = await _makeController(initial: [_record('a')]); + await tester.pumpWidget( + _wrap( + controller, + photoService: fakePhotoService( + picker: (_) async => + PickedPhoto(bytes: tinyPng, contentType: 'image/png'), + ), ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.add_a_photo_outlined)); - await tester.pumpAndSettle(); - // The binding checks this before tearDown callbacks run, so it cannot be - // reset with addTearDown. - debugDefaultTargetPlatformOverride = null; - - expect(controller.items.single.photoRef, 'key-0.jpg'); - expect(find.byType(Image), findsOneWidget); - controller.dispose(); + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add_a_photo_outlined)); + await tester.pumpAndSettle(); + + expect(controller.items.single.photoRef, 'key-0.jpg'); + expect(find.byType(Image), findsOneWidget); + expect(sync.upserts, isNotEmpty); + } finally { + debugDefaultTargetPlatformOverride = null; + } }); testWidgets('the viewer removes the photo and its stored key', ( @@ -292,7 +287,6 @@ void main() { expect(controller.items.single.photoRef, isNull); expect(stored, isEmpty); - controller.dispose(); }); testWidgets('delete button removes the record', (tester) async { @@ -313,44 +307,45 @@ void main() { await tester.pumpAndSettle(); expect(controller.items, isEmpty); - controller.dispose(); }); testWidgets('a photo captured in the add editor lands on the new record', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; - final controller = await _makeController(); - await tester.pumpWidget( - _wrap( - controller, - photoService: fakePhotoService( - picker: (_) async => - PickedPhoto(bytes: tinyPng, contentType: 'image/png'), + try { + final controller = await _makeController(); + await tester.pumpWidget( + _wrap( + controller, + photoService: fakePhotoService( + picker: (_) async => + PickedPhoto(bytes: tinyPng, contentType: 'image/png'), + ), ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); - - // The editor sheet's empty photo affordance offers capture. - expect(find.text('Add packing item'), findsOneWidget); - await tester.tap(find.text('Add photo')); - await tester.pumpAndSettle(); - - // Name the item and save; the returned photoRef rides along. - await tester.enterText(find.byType(TextField), 'Wrench Set'); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Add item').last); - await tester.pumpAndSettle(); - - debugDefaultTargetPlatformOverride = null; - - expect(controller.items.length, 1); - expect(controller.items.single.itemId, 'Wrench Set'); - expect(controller.items.single.photoRef, 'key-0.jpg'); - controller.dispose(); + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // The editor sheet's empty photo affordance offers capture. + expect(find.text('Add packing item'), findsOneWidget); + await tester.tap(find.text('Add photo')); + await tester.pumpAndSettle(); + + // Name the item and save; the returned photoRef rides along. + await tester.enterText(find.byType(TextField), 'Wrench Set'); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Add item').last); + await tester.pumpAndSettle(); + + expect(controller.items.length, 1); + expect(controller.items.single.itemId, 'Wrench Set'); + expect(controller.items.single.photoRef, 'key-0.jpg'); + expect(sync.upserts, isNotEmpty); + } finally { + debugDefaultTargetPlatformOverride = null; + } }); } diff --git a/test/photo_disk_cache_test.dart b/test/photo_disk_cache_test.dart new file mode 100644 index 0000000..8c08398 --- /dev/null +++ b/test/photo_disk_cache_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:spectrumpit/src/services/photo_disk_cache.dart'; + +// #164: photos were cached in memory only, so scrolling the packing list after a +// relaunch refetched every image over venue wifi. The disk cache keeps them +// across launches, bounded by total size and evicted least-recently-read. +void main() { + late Directory base; + late PhotoDiskCache cache; + + Uint8List bytes(int size, [int fill = 7]) => + Uint8List.fromList(List.filled(size, fill)); + + setUp(() async { + base = await Directory.systemTemp.createTemp('photo_cache_test'); + cache = PhotoDiskCache( + directoryLoader: () async => Directory('${base.path}/photos'), + maxBytes: 1000, + ); + }); + + tearDown(() async { + if (await base.exists()) await base.delete(recursive: true); + }); + + test('a written photo reads back', () async { + await cache.write('abc', bytes(10, 3)); + + expect(await cache.read('abc'), bytes(10, 3)); + }); + + test('a miss is null, not an error', () async { + // The cache must never fail a fetch, only save one. + expect(await cache.read('never-stored'), isNull); + }); + + test('remove drops it', () async { + await cache.write('abc', bytes(10)); + await cache.remove('abc'); + + expect(await cache.read('abc'), isNull); + }); + + test('removing something absent is not an error', () async { + // delete() calls this unconditionally. + await cache.remove('never-stored'); + }); + + test('clear empties everything', () async { + await cache.write('a', bytes(10)); + await cache.write('b', bytes(10)); + + await cache.clear(); + + expect(await cache.read('a'), isNull); + expect(await cache.read('b'), isNull); + expect(await cache.currentBytes(), 0); + }); + + test('the cache is trimmed back under its limit', () async { + // Four 300-byte photos against a 1000-byte cap: one has to go. + await cache.write('a', bytes(300)); + await cache.write('b', bytes(300)); + await cache.write('c', bytes(300)); + await cache.write('d', bytes(300)); + + expect(await cache.currentBytes(), lessThanOrEqualTo(1000)); + }); + + test( + 'eviction drops the least recently read, not the oldest written', + () async { + await cache.write('old', bytes(400)); + await cache.write('newer', bytes(400)); + + // Times set explicitly rather than by sleeping between writes: wall-clock + // gaps made this flaky, and the eviction order is the only thing under test. + // 'old' is the most recently read, so it must survive. + final dir = Directory('${base.path}/photos'); + await File('${dir.path}/old').setLastModified(DateTime.now()); + await File( + '${dir.path}/newer', + ).setLastModified(DateTime.now().subtract(const Duration(hours: 1))); + + await cache.write('pushes-over', bytes(400)); + + expect( + await cache.read('old'), + isNotNull, + reason: 'it was read most recently', + ); + expect(await cache.read('newer'), isNull, reason: 'it was the stale one'); + }, + ); + + test('a key that could escape the directory reads as a miss', () async { + // Keys come from the Worker and should be safe, but they end up in a path, + // so the guard refuses them. read() still returns null rather than throwing: + // the cache must never fail a fetch, only save one. + for (final key in ['', '../escape', 'a/b', r'a\b', '..']) { + expect(await cache.read(key), isNull, reason: 'key: $key'); + } + }); + + test('a key that could escape the directory writes nothing', () async { + await cache.write('../escape', bytes(10)); + + // Nothing landed anywhere, including outside the cache directory. + expect(await cache.currentBytes(), 0); + expect(await File('${base.path}/escape').exists(), isFalse); + }); + + test('reading from an empty cache directory reports zero bytes', () async { + expect(await cache.currentBytes(), 0); + }); +} diff --git a/test/photo_service_test.dart b/test/photo_service_test.dart index d74702d..75b1f3f 100644 --- a/test/photo_service_test.dart +++ b/test/photo_service_test.dart @@ -191,6 +191,8 @@ void main() { test('pickImage hands each platform its own source, returns the bytes, and ' 'never uploads', () async { + // Restore the platform override even if an expectation fails below. + addTearDown(() => debugDefaultTargetPlatformOverride = null); for (final (platform, expected) in [ (TargetPlatform.iOS, PhotoSource.gallery), (TargetPlatform.linux, PhotoSource.file), @@ -212,18 +214,17 @@ void main() { expect(picked!.bytes, photo.bytes); expect(requests, isEmpty); } - debugDefaultTargetPlatformOverride = null; final cancelled = fakePhotoService(picker: (_) async => null); expect(await cancelled.pickImage(), isNull); }); test('sources follow the platform: camera on mobile, files elsewhere', () { + addTearDown(() => debugDefaultTargetPlatformOverride = null); final service = fakePhotoService(); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; expect(service.sources, [PhotoSource.camera, PhotoSource.gallery]); debugDefaultTargetPlatformOverride = TargetPlatform.linux; expect(service.sources, [PhotoSource.file]); - debugDefaultTargetPlatformOverride = null; }); } diff --git a/test/pit_shift_test.dart b/test/pit_shift_test.dart index eb95bc6..4ae175f 100644 --- a/test/pit_shift_test.dart +++ b/test/pit_shift_test.dart @@ -68,6 +68,12 @@ void main() { expect(shift.startsAt, isNull); expect(shift.endsAt, isNull); expect(shift.notes, isNull); + // Absent updatedAt falls back to the Unix epoch in UTC, matching the + // other pit models. + expect( + shift.updatedAt, + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); }); test('drops non-string entries from the assignment lists', () { @@ -91,6 +97,8 @@ void main() { assignedNames: const ['Cass'], startMatch: 30, endMatch: 45, + startsAt: DateTime.utc(2026, 4, 10, 8), + endsAt: DateTime.utc(2026, 4, 10, 12), notes: 'Queue early', updatedAt: DateTime.utc(2026, 3, 15), ); @@ -102,6 +110,8 @@ void main() { expect(restored.assignedNames, original.assignedNames); expect(restored.startMatch, original.startMatch); expect(restored.endMatch, original.endMatch); + expect(restored.startsAt, original.startsAt); + expect(restored.endsAt, original.endsAt); expect(restored.notes, original.notes); expect(restored.updatedAt, original.updatedAt); }); @@ -300,6 +310,31 @@ void main() { expect(duty.conflictsWith(away), isTrue); }); + test('an inverted match range still conflicts through its edges', () { + // The editor refuses inverted ranges (start > end), but if one reaches + // conflict detection the bounds are compared as-is: it overlaps any + // range that touches both edges and no range sitting entirely between + // them. A range touching only one edge does not conflict -- the overlap + // math needs a span from the lower bound through the upper bound. + final inverted = _shift('a', startMatch: 20, endMatch: 5); + expect( + inverted.conflictsWith(_shift('b', startMatch: 1, endMatch: 30)), + isTrue, + ); + expect( + inverted.conflictsWith(_shift('c', startMatch: 10, endMatch: 15)), + isFalse, + ); + expect( + inverted.conflictsWith(_shift('d', startMatch: 1, endMatch: 5)), + isFalse, + ); + expect( + inverted.conflictsWith(_shift('e', startMatch: 20, endMatch: 30)), + isFalse, + ); + }); + test('two unavailable blocks for the same person do not conflict', () { final a = _shift( 'a', diff --git a/test/schedule_tab_test.dart b/test/schedule_tab_test.dart index ed664df..3c36ec0 100644 --- a/test/schedule_tab_test.dart +++ b/test/schedule_tab_test.dart @@ -46,14 +46,18 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late FakePitShiftSyncService sync; - late PitShiftController controller; + PitShiftController? controller; setUp(() { sync = FakePitShiftSyncService(); + // Cleared here, not only in tearDown: pumpTab assigns it, so a failure + // before that line would leave teardown disposing the previous test's + // controller (#169). + controller = null; SharedPreferences.setMockInitialValues({}); }); - tearDown(() => controller.dispose()); + tearDown(() => controller?.dispose()); Future pumpTab( WidgetTester tester, { @@ -84,7 +88,7 @@ void main() { ); await roleController.bootstrap(); controller = PitShiftController(authService: auth, syncService: sync); - await controller.bootstrap(); + await controller!.bootstrap(); if (shifts.isNotEmpty && user != null) sync.emit(shifts); await tester.pumpWidget( @@ -92,7 +96,7 @@ void main() { theme: buildDarkAppTheme(), home: Scaffold( body: ScheduleTab( - controller: controller, + controller: controller!, authService: auth, roleController: roleController, ), @@ -325,4 +329,63 @@ void main() { expect(find.text('Not signed in'), findsOneWidget); }); + + testWidgets('cancelling a delete keeps the shift and records no delete', ( + tester, + ) async { + await pumpTab( + tester, + shifts: [_shift('a', label: 'Qual block', startMatch: 18, endMatch: 34)], + ); + + await tester.tap(find.text('Qual block')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(sync.deletes, isEmpty); + expect(controller!.items.single.id, 'a'); + }); + + testWidgets('a confirmed delete records the shift id and closes the editor', ( + tester, + ) async { + await pumpTab( + tester, + shifts: [_shift('a', label: 'Qual block', startMatch: 18, endMatch: 34)], + ); + + await tester.tap(find.text('Qual block')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(sync.deletes, ['a']); + expect(controller!.items, isEmpty); + expect(find.text('Edit shift'), findsNothing); + }); + + testWidgets('a failed delete keeps the editor sheet open', (tester) async { + await pumpTab( + tester, + shifts: [_shift('a', label: 'Qual block', startMatch: 18, endMatch: 34)], + ); + sync.failWith = Exception('offline'); + + await tester.tap(find.text('Qual block')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + // The write failed, so the sheet stays open and the shift is untouched. + expect(find.text('Edit shift'), findsOneWidget); + expect(controller!.items.single.id, 'a'); + }); } diff --git a/test/support/fake_map_diagram_sync_service.dart b/test/support/fake_map_diagram_sync_service.dart index dc6b06b..f15b7e1 100644 --- a/test/support/fake_map_diagram_sync_service.dart +++ b/test/support/fake_map_diagram_sync_service.dart @@ -19,7 +19,7 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { _writeFailure = writeFailure, _clearFailure = clearFailure; - final String? _readKeyValue; + String? _readKeyValue; final Object? _readFailure; final Object? _writeFailure; final Object? _clearFailure; @@ -46,11 +46,19 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { await onWriteKey?.call(mapType, key); writeCalls.add((mapType: mapType, key: key)); if (_writeFailure != null) throw _writeFailure; + // Store the pointer, so a later readKey reflects the write. clearKey + // already did the mirror of this; without it the fake reported a stale key + // after a successful write and no test could catch a read-after-write + // mistake (#169). A failed write deliberately does not update it. + _readKeyValue = key; } @override Future clearKey(MapType mapType) async { clearCalls.add(mapType); if (_clearFailure != null) throw _clearFailure; + // Clear the stored pointer so a later readKey reflects the cleared state, + // rather than leaving diagramFor to discover a deleted object by 404. + _readKeyValue = null; } } diff --git a/test/support/fake_map_image_store.dart b/test/support/fake_map_image_store.dart index df82255..c71e2d4 100644 --- a/test/support/fake_map_image_store.dart +++ b/test/support/fake_map_image_store.dart @@ -20,10 +20,14 @@ class FakeMapImageStore implements MapImageStore { bool isSupported = true; @override - Future diagramFor(MapType mapType) async => images[mapType]; + Future diagramFor(MapType mapType) async { + if (!isSupported) return null; + return images[mapType]; + } @override Future pickDiagram(MapType mapType) async { + if (!isSupported) return null; final diagram = nextPick; if (diagram != null) images[mapType] = diagram; return diagram; @@ -31,6 +35,7 @@ class FakeMapImageStore implements MapImageStore { @override Future clearDiagram(MapType mapType) async { + if (!isSupported) return; if (clearFailure != null) throw clearFailure!; final gate = clearGate; if (gate != null) await gate; diff --git a/test/support/fake_map_location_sync_service.dart b/test/support/fake_map_location_sync_service.dart index 2a00dcc..a9d085a 100644 --- a/test/support/fake_map_location_sync_service.dart +++ b/test/support/fake_map_location_sync_service.dart @@ -29,6 +29,10 @@ class FakeMapLocationSyncService implements MapLocationSyncService { /// Push a snapshot to simulate a realtime emission (used in tests). void emit(List items) => _controller.add(items); + /// Publish a stream error, like the real backend does on a permission-denied + /// read. + void emitError(Object error) => _controller.addError(error); + @override Future> fetchAll() async => _items.values.toList(); diff --git a/test/support/fake_pit_shift_sync_service.dart b/test/support/fake_pit_shift_sync_service.dart index ee6e5b5..85b413b 100644 --- a/test/support/fake_pit_shift_sync_service.dart +++ b/test/support/fake_pit_shift_sync_service.dart @@ -12,6 +12,20 @@ class FakePitShiftSyncService implements PitShiftSyncService { final List upserts = []; final List deletes = []; + /// Set to make the next [upsert] or [delete] call fail, simulating an + /// offline or auth-expired sync write. + Object? failWith; + + // Fires once: the doc above promises the NEXT write fails, so clear it as it + // throws. Retaining it would fail every later write and make a recovery path + // impossible to test. + void _throwIfConfigured() { + final failure = failWith; + if (failure == null) return; + failWith = null; + throw failure; + } + /// Push a snapshot to simulate a realtime emission (used in tests). void emit(List items) => _controller.add(items); @@ -22,12 +36,14 @@ class FakePitShiftSyncService implements PitShiftSyncService { @override Future upsert(PitShift shift) async { + _throwIfConfigured(); upserts.add(shift); _items[shift.id] = shift; } @override Future delete(String id) async { + _throwIfConfigured(); deletes.add(id); _items.remove(id); } diff --git a/test/support/fake_user_role_service.dart b/test/support/fake_user_role_service.dart index 38708d4..329581c 100644 --- a/test/support/fake_user_role_service.dart +++ b/test/support/fake_user_role_service.dart @@ -1,18 +1,28 @@ +import 'dart:async'; + import 'package:spectrumpit/src/models/user_profile.dart'; import 'package:spectrumpit/src/models/user_role.dart'; -import 'package:spectrumpit/src/services/user_role_service.dart'; +import 'package:spectrumpit/src/services/user_role_service_interface.dart'; class FakeUserRoleService implements UserRoleService { final Map> _roles = {}; final Map _displayNames = {}; + final StreamController> _profiles = + StreamController>.broadcast(); // Pre-set a specific role set for a UID. If not set, fetchOrCreateRoles // will auto-assign viewer (mirroring real first-sign-in behaviour: the // no-access default until an admin promotes the account, #334). - void setRoles(String uid, Set roles) => _roles[uid] = roles; + void setRoles(String uid, Set roles) { + _roles[uid] = roles; + _emitProfiles(); + } // Convenience wrapper for single-role tests. - void setRole(String uid, UserRole role) => _roles[uid] = {role}; + void setRole(String uid, UserRole role) { + _roles[uid] = {role}; + _emitProfiles(); + } @override Future> fetchOrCreateRoles({ @@ -23,6 +33,7 @@ class FakeUserRoleService implements UserRoleService { if (!_roles.containsKey(uid)) { _roles[uid] = {UserRole.viewer}; _displayNames[uid] = displayName; + _emitProfiles(); } return _roles[uid]!; } @@ -30,20 +41,40 @@ class FakeUserRoleService implements UserRoleService { @override Future updateRoles(String uid, Set roles) async { _roles[uid] = roles; + _emitProfiles(); } @override - Stream> streamAllProfiles() { - return Stream.value( - _roles.entries - .map( - (e) => UserProfile( - uid: e.key, - displayName: _displayNames[e.key] ?? e.key, - roles: e.value, - ), - ) - .toList(), + Stream> streamAllProfiles() async* { + // Emit the current roster first (the real service emits on first read), + // then forward every subsequent mutation. A per-listener generator is + // used instead of pre-adding to the broadcast controller, because a + // broadcast drops events added before the caller subscribes. + yield _currentProfiles(); + yield* _profiles.stream; + } + + List _currentProfiles() { + final profiles = _roles.entries + .map( + (e) => UserProfile( + uid: e.key, + displayName: _displayNames[e.key] ?? e.key, + roles: e.value, + ), + ) + .toList(); + // streamAllProfiles' contract is display-name ordering, matching the real + // services' client-side sort. + profiles.sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), ); + return profiles; + } + + void _emitProfiles() { + if (_profiles.isClosed) return; + _profiles.add(_currentProfiles()); } } diff --git a/test/support/photo_test_support.dart b/test/support/photo_test_support.dart index f841680..fba3108 100644 --- a/test/support/photo_test_support.dart +++ b/test/support/photo_test_support.dart @@ -42,6 +42,13 @@ PhotoService fakePhotoService({ cacheLimit: cacheLimit, httpClient: MockClient((request) async { requests?.add(request); + // The Worker authenticates every request with the caller's Bearer token; + // simulate that gate so an unauthenticated request cannot hit the + // storage logic. + final expected = token == null ? null : 'Bearer $token'; + if (request.headers['Authorization'] != expected) { + return http.Response('{"error":"Unauthorized"}', 401); + } if (respond != null) return respond(request); final key = request.url.pathSegments.length > 1 ? request.url.pathSegments.last diff --git a/test/synced_map_image_store_test.dart b/test/synced_map_image_store_test.dart index d8be88b..b9ff628 100644 --- a/test/synced_map_image_store_test.dart +++ b/test/synced_map_image_store_test.dart @@ -81,12 +81,16 @@ void main() { final offlineSync = FakeMapDiagramSyncService( readFailure: Exception('firestore read failed'), ); + final requests = []; final offlineStore = SyncedMapImageStore( - photoService: photoServiceReturningPng(), + photoService: fakePhotoService(requests: requests), diagramSync: offlineSync, ); final fallback = await offlineStore.diagramFor(MapType.lab); expect(fallback, isNotNull, reason: 'offline read should use the cache'); + // The fallback came entirely from the local cache: no network request + // was issued by the photo service. + expect(requests, isEmpty); }); test('readKey throwing with no cached file returns null', () async { @@ -184,10 +188,11 @@ void main() { expect(await store.diagramFor(MapType.lab), isNull); }); - test('remote clear failure leaves the local diagram intact', () async { - // If the remote pointer cannot be cleared, the device must keep showing - // the diagram rather than drop to the empty state and disagree with the - // rest of the team (#155). + test('remote clear failure still cleans up the local cache', () async { + // Each remote step is best-effort on its own (#161): a failed remote + // pointer clear must not prevent the local cache and preferences from + // being removed. The remote pointer survives (the team still sees the + // diagram), but this device no longer claims it. final sync = FakeMapDiagramSyncService( readKeyValue: 'key-0.jpg', clearFailure: Exception('remote clear failed'), @@ -198,14 +203,14 @@ void main() { ); await store.pickDiagram(MapType.lab); - await expectLater( - store.clearDiagram(MapType.lab), - throwsA(isA()), - ); + // Completes instead of throwing: the failure is contained to the remote + // pointer step. + await store.clearDiagram(MapType.lab); + expect(await sync.readKey(MapType.lab), 'key-0.jpg'); final prefs = await SharedPreferences.getInstance(); - expect(prefs.getString('$_r2KeyPref${MapType.lab.name}'), 'key-0.jpg'); - expect(_appSupport.listSync(), isNotEmpty); + expect(prefs.getString('$_r2KeyPref${MapType.lab.name}'), isNull); + expect(_appSupport.listSync(), isEmpty); }); }); } diff --git a/test/telemetry_service_test.dart b/test/telemetry_service_test.dart index c8fd486..c2ed9f6 100644 --- a/test/telemetry_service_test.dart +++ b/test/telemetry_service_test.dart @@ -39,24 +39,49 @@ void main() { firestore: firestore, debugInfo: () async => _info, ); + await service.setEnabled(true); - await service.logEvent('app_open'); + // A detail makes every optional key present, so the written key set is + // exactly the full whitelist below. + await service.logEvent('app_open', detail: 'first-launch'); final snap = await firestore.collection('telemetry').get(); expect(snap.docs, hasLength(1)); final data = snap.docs.single.data(); - expect(data.keys.every(_allowedKeys.contains), isTrue); + // Exact set equality against isValidTelemetry's whitelist (firestore.rules): + // if the rules' required fields change, this expected set must change too. + expect(data.keys.toSet(), _allowedKeys); expect(data['type'], 'app_open'); expect(data['deviceId'], isNotEmpty); expect(data['platform'], 'linux'); expect(data['appVersion'], contains('1.2.3')); expect(data['id'], snap.docs.single.id); - expect(data.containsKey('detail'), isFalse); + expect(data['detail'], 'first-launch'); final createdAt = data['createdAt'] as String; expect(DateTime.tryParse(createdAt), isNotNull); }); + test( + 'logEvent clamps over-length type and detail to the rule bounds', + () async { + final firestore = FakeFirebaseFirestore(); + final service = TelemetryService( + firestore: firestore, + debugInfo: () async => _info, + ); + await service.setEnabled(true); + + await service.logEvent('x' * 200, detail: 'y' * 500); + + final data = (await firestore.collection('telemetry').get()).docs.single + .data(); + // Persisted values are clamped, not just the in-memory inputs. + expect((data['type'] as String).length, 64); + expect((data['detail'] as String).length, 128); + }, + ); + test('logEvent is a no-op when telemetry is disabled', () async { final firestore = FakeFirebaseFirestore(); final service = TelemetryService( @@ -75,7 +100,9 @@ void main() { TelemetryService make() => TelemetryService(firestore: firestore, debugInfo: () async => _info); - await make().logEvent('app_open'); + final first = make(); + await first.setEnabled(true); + await first.logEvent('app_open'); await make().logEvent('tab_open', detail: 'Strategy'); final docs = (await firestore.collection('telemetry').get()).docs; @@ -100,6 +127,7 @@ void main() { written = data; }, ); + await service.setEnabled(true); await service.logEvent('tab_open', detail: 'Prematch'); @@ -107,4 +135,82 @@ void main() { expect(written!['type'], 'tab_open'); expect(written!['detail'], 'Prematch'); }); + + // #169: the stored preference has three states and only null means "never + // touched". false and null both mean "not collecting" now, but Settings shows + // them differently, so a refusal that decayed back to null would read as an + // untouched install and flip collection back on. + group('storedPreference after a change', () { + test('is false after the toggle is turned off, not null', () async { + final service = TelemetryService( + firestore: FakeFirebaseFirestore(), + debugInfo: () async => _info, + ); + await service.setEnabled(false); + + expect(await service.storedPreference(), isFalse); + }); + + test('a later change replaces the earlier answer', () async { + final service = TelemetryService( + firestore: FakeFirebaseFirestore(), + debugInfo: () async => _info, + ); + await service.setEnabled(true); + await service.setEnabled(false); + + expect(await service.storedPreference(), isFalse); + }); + }); + + // #168: the maintainer's call is that both apps behave the same, and Strategy + // is opt-out. So an untouched install collects, and the Settings toggle is how + // somebody turns it off. + group('opt-out default', () { + test('an untouched install is enabled', () async { + expect(await TelemetryService().isEnabled(), isTrue); + }); + + test('turning it off sticks', () async { + await TelemetryService().setEnabled(false); + expect(await TelemetryService().isEnabled(), isFalse); + }); + + test('storedPreference is null until the toggle is touched', () async { + // Settings uses this to tell "on by default" from "deliberately on". + // Nothing prompts on it any more. + expect(await TelemetryService().storedPreference(), isNull); + await TelemetryService().setEnabled(true); + expect(await TelemetryService().storedPreference(), isTrue); + }); + }); + + test('an untouched install actually transmits', () async { + // isEnabled and logEvent read the same preference separately, and flipping + // only one left collection off for every untouched install while the toggle + // claimed otherwise (#168). + final firestore = FakeFirebaseFirestore(); + final service = TelemetryService( + firestore: firestore, + debugInfo: () async => _info, + ); + + await service.logEvent('app_open'); + + final snap = await firestore.collection('telemetry').get(); + expect(snap.docs, hasLength(1)); + }); + + test('turning it off stops transmission', () async { + final firestore = FakeFirebaseFirestore(); + final service = TelemetryService( + firestore: firestore, + debugInfo: () async => _info, + ); + await service.setEnabled(false); + + await service.logEvent('app_open'); + + expect((await firestore.collection('telemetry').get()).docs, isEmpty); + }); } diff --git a/test/user_management_screen_test.dart b/test/user_management_screen_test.dart index 24843aa..d9f491a 100644 --- a/test/user_management_screen_test.dart +++ b/test/user_management_screen_test.dart @@ -34,6 +34,7 @@ void main() { authService: FakeSpectrumAuthService(), roleService: roleService, ); + addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( @@ -68,6 +69,7 @@ void main() { ), roleService: roleService, ); + addTearDown(controller.dispose); await controller.bootstrap(); await tester.pumpWidget( diff --git a/test/user_profile_test.dart b/test/user_profile_test.dart new file mode 100644 index 0000000..6aea370 --- /dev/null +++ b/test/user_profile_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:spectrumpit/src/models/user_profile.dart'; +import 'package:spectrumpit/src/models/user_role.dart'; + +UserProfile _profile(String uid, String displayName) => UserProfile( + uid: uid, + displayName: displayName, + roles: const {UserRole.viewer}, +); + +void main() { + group('byDisplayName', () { + test('orders case-insensitively by display name', () { + final profiles = [ + _profile('u1', 'zoe'), + _profile('u2', 'Alice'), + _profile('u3', 'bob'), + ]..sort(UserProfile.byDisplayName); + expect(profiles.map((p) => p.uid), ['u2', 'u3', 'u1']); + }); + + test('breaks a display-name tie on uid, whatever the input order', () { + final ascending = [ + _profile('u1', 'Sam'), + _profile('u2', 'sam'), + _profile('u3', 'SAM'), + ]..sort(UserProfile.byDisplayName); + final descending = [ + _profile('u3', 'SAM'), + _profile('u2', 'sam'), + _profile('u1', 'Sam'), + ]..sort(UserProfile.byDisplayName); + + expect(ascending.map((p) => p.uid), ['u1', 'u2', 'u3']); + expect(descending.map((p) => p.uid), ascending.map((p) => p.uid)); + }); + + test('keeps profiles with no display name together and first', () { + final profiles = [ + _profile('u2', 'Alice'), + _profile('u3', ''), + _profile('u1', ''), + ]..sort(UserProfile.byDisplayName); + expect(profiles.map((p) => p.uid), ['u1', 'u3', 'u2']); + }); + }); +} diff --git a/test/user_role_controller_test.dart b/test/user_role_controller_test.dart index 8cc1cc5..22db5c3 100644 --- a/test/user_role_controller_test.dart +++ b/test/user_role_controller_test.dart @@ -7,6 +7,23 @@ import 'support/fake_spectrum_auth_service.dart'; import 'support/fake_user_role_service.dart'; void main() { + // Named tab identifiers from the model, so the expectations stay aligned + // with shell routing instead of bare numbers. + const featureTabs = [ + AppTabs.inventory, + AppTabs.packing, + AppTabs.borrowed, + AppTabs.maps, + AppTabs.schedule, + ]; + const pitTabs = [...featureTabs, AppTabs.docs, AppTabs.settings]; + const adminTabs = [ + ...featureTabs, + AppTabs.docs, + AppTabs.users, + AppTabs.settings, + ]; + group('UserRole.fromString', () { test('parses known role strings', () { expect(UserRole.fromString('viewer'), UserRole.viewer); @@ -46,7 +63,7 @@ void main() { }); test('pit: feature tabs + Docs + Settings', () { - expect({UserRole.pit}.visibleTabIndices, [0, 1, 2, 3, 4, 5, 7]); + expect({UserRole.pit}.visibleTabIndices, pitTabs); }); // isMember gates the in-app problem-reporting surface (#438): everyone @@ -61,20 +78,20 @@ void main() { test('admin: all tabs including Users, canManageUsers', () { final roles = {UserRole.admin}; - expect(roles.visibleTabIndices, [0, 1, 2, 3, 4, 5, 6, 7]); + expect(roles.visibleTabIndices, adminTabs); expect(roles.canManageUsers, isTrue); }); test('developer: feature tabs + Docs + Settings, no Users, isDebug', () { final roles = {UserRole.developer}; - expect(roles.visibleTabIndices, [0, 1, 2, 3, 4, 5, 7]); + expect(roles.visibleTabIndices, pitTabs); expect(roles.isDebug, isTrue); expect(roles.canManageUsers, isFalse); }); test('multi-role union: pit + admin = all tabs', () { final roles = {UserRole.pit, UserRole.admin}; - expect(roles.visibleTabIndices, [0, 1, 2, 3, 4, 5, 6, 7]); + expect(roles.visibleTabIndices, adminTabs); expect(roles.canManageUsers, isTrue); }); @@ -82,7 +99,7 @@ void main() { 'multi-role union: admin + developer = all tabs + isDebug + canManage', () { final roles = {UserRole.admin, UserRole.developer}; - expect(roles.visibleTabIndices, [0, 1, 2, 3, 4, 5, 6, 7]); + expect(roles.visibleTabIndices, adminTabs); expect(roles.canManageUsers, isTrue); expect(roles.isDebug, isTrue); }, @@ -248,8 +265,8 @@ void main() { await controller.bootstrap(); await Future.delayed(Duration.zero); - expect( - () => controller.updateUserRoles('uid-x', {UserRole.pit}), + await expectLater( + controller.updateUserRoles('uid-x', {UserRole.pit}), throwsStateError, ); controller.dispose(); @@ -268,8 +285,8 @@ void main() { await controller.bootstrap(); await Future.delayed(Duration.zero); - expect( - () => controller.updateUserRoles('uid-admin', {UserRole.viewer}), + await expectLater( + controller.updateUserRoles('uid-admin', {UserRole.viewer}), throwsStateError, ); controller.dispose(); diff --git a/test/widget_test.dart b/test/widget_test.dart index e003174..1b93691 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -246,4 +247,57 @@ void main() { findsNothing, ); }); + + // #115: both desktop guidelines this app is measured against treat full + // keyboard operation as a requirement, and the app ships desktop builds. There + // was no keyboard path to the destinations at all. + group('keyboard destination navigation', () { + Future press(WidgetTester tester, LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + } + + int selected(WidgetTester tester) => + tester.widget(find.byType(NavigationBar)).selectedIndex; + + testWidgets('ctrl+] moves forward and ctrl+[ moves back', (tester) async { + const user = SpectrumUser(uid: 'admin-uid', displayName: 'Admin'); + await _buildShell( + tester, + signedInUser: user, + userRoles: {UserRole.admin}, + ); + + expect(selected(tester), 0); + + await press(tester, LogicalKeyboardKey.bracketRight); + expect(selected(tester), 1); + + await press(tester, LogicalKeyboardKey.bracketLeft); + expect(selected(tester), 0); + }); + + testWidgets('it wraps at both ends rather than sticking', (tester) async { + const user = SpectrumUser(uid: 'admin-uid', displayName: 'Admin'); + await _buildShell( + tester, + signedInUser: user, + userRoles: {UserRole.admin}, + ); + final count = tester + .widget(find.byType(NavigationBar)) + .destinations + .length; + + // Back from the first lands on the last: stopping there would make the + // shortcut feel broken, and there is no ordering meaning to preserve. + await press(tester, LogicalKeyboardKey.bracketLeft); + expect(selected(tester), count - 1); + + await press(tester, LogicalKeyboardKey.bracketRight); + expect(selected(tester), 0); + }); + }); } diff --git a/tool/generate_icons.py b/tool/generate_icons.py index 7139a10..e61c312 100644 --- a/tool/generate_icons.py +++ b/tool/generate_icons.py @@ -54,7 +54,8 @@ def monochrome(size): def redraw(path, maker): - size = Image.open(path).size[0] + with Image.open(path) as img: + size = max(img.size) maker(size).save(path) print(f"{path.relative_to(ROOT)} ({size})") diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 44ba1e9..a45a1ab 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "Spectrum 3847" "\0" - VALUE "FileDescription", "spectrumpit" "\0" + VALUE "FileDescription", "Spectrum Pit" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "spectrumpit" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 Spectrum 3847. All rights reserved." "\0" VALUE "OriginalFilename", "spectrumpit.exe" "\0" - VALUE "ProductName", "spectrumpit" "\0" + VALUE "ProductName", "Spectrum Pit" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee30..8402f27 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -59,12 +59,12 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, if (result) { return *result; } - } - switch (message) { - case WM_FONTCHANGE: + // Reload fonts when the system font set changes. Only valid while the + // controller exists; OnDestroy may have cleared it. + if (message == WM_FONTCHANGE) { flutter_controller_->engine()->ReloadSystemFonts(); - break; + } } return Win32Window::MessageHandler(hwnd, message, wparam, lparam);