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