Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
prompt have to stay together. Reading the photo library needs no
permission on the API levels this app supports. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Required so release builds always have network access; Flutter's debug
and profile manifests add INTERNET themselves, but a release build must
not depend on them or on transitive libraries. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- CAMERA is an optional feature: the app works without a camera (packing
photos just fall back to the photo library), so declare it not required
to keep Google Play listing the app on camera-less devices. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:label="Spectrum Pit"
android:name="${applicationName}"
Expand Down
Binary file added assets/fonts/IBMPlexMono-Medium.ttf
Binary file not shown.
Binary file added assets/fonts/IBMPlexSans-Bold.ttf
Binary file not shown.
Binary file added assets/fonts/IBMPlexSans-Regular.ttf
Binary file not shown.
Binary file added assets/fonts/IBMPlexSans-SemiBold.ttf
Binary file not shown.
23 changes: 22 additions & 1 deletion firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ service cloud.firestore {
&& isValidNewProfile(request.resource.data);
allow update: if isAuthed()
&& isAdmin()
&& request.auth.uid != uid;
&& request.auth.uid != uid
&& isValidProfileUpdate(request.resource.data);
}

match /appConfig/{docId} {
Expand Down Expand Up @@ -95,6 +96,8 @@ service cloud.firestore {
match /telemetry/{eventId} {
allow read: if false;
allow create: if isAuthed()
&& isMember()
&& request.resource.data.id == eventId
&& isValidTelemetry(request.resource.data);
allow update, delete: if false;
}
Expand Down Expand Up @@ -157,6 +160,24 @@ service cloud.firestore {
&& (!('createdAt' in data) || isSaneTimestamp(data.createdAt));
}

function isValidProfileUpdate(data) {
return data.keys().hasOnly(['uid', 'displayName', 'email', 'roles',
'createdAt'])
&& (!('uid' in data) || data.uid == resource.data.uid)
&& (!('createdAt' in data)
|| data.createdAt == resource.data.createdAt)
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['displayName', 'email', 'roles'])
&& data.roles is list
&& data.roles.size() >= 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'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
15 changes: 8 additions & 7 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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';
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';
Expand Down Expand Up @@ -158,15 +160,14 @@ Future<void> 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,
Expand Down
17 changes: 17 additions & 0 deletions lib/src/app.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';

import 'services/issue_report_service.dart';
Expand Down Expand Up @@ -51,10 +53,16 @@ class StrategyApp extends StatefulWidget {

class _StrategyAppState extends State<StrategyApp> {
late Future<void> _bootstrapFuture;
StreamSubscription<SpectrumAuthSnapshot>? _authSubscription;

bool _wasSignedIn = false;

@override
void initState() {
super.initState();
_authSubscription = widget.authService.snapshotStream.listen(
_onAuthSnapshot,
);
widget.themeController.addListener(_onThemeChanged);
_bootstrapFuture = _startBootstrap();
}
Expand Down Expand Up @@ -83,6 +91,7 @@ class _StrategyAppState extends State<StrategyApp> {
@override
void dispose() {
widget.themeController.removeListener(_onThemeChanged);
_authSubscription?.cancel();
widget.authService.dispose();
widget.themeController.dispose();
widget.userRoleController.dispose();
Expand All @@ -95,6 +104,14 @@ class _StrategyAppState extends State<StrategyApp> {
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(
Expand Down
6 changes: 4 additions & 2 deletions lib/src/models/inventory_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) {
Expand All @@ -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,
);
Expand Down
3 changes: 2 additions & 1 deletion lib/src/models/packing_record.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
12 changes: 8 additions & 4 deletions lib/src/models/pit_shift.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ class PitShift implements PitModel {
}

factory PitShift.fromJson(String id, Map<String, dynamic> data) {
final startsAtRaw = data['startsAt'] as String?;
final endsAtRaw = data['endsAt'] as String?;
return PitShift(
id: id,
label: data['label'] as String? ?? '',
Expand All @@ -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? ?? '') ??
Expand All @@ -99,6 +97,12 @@ class PitShift implements PitModel {
? value.whereType<String>().toList(growable: false)
: const <String>[];

static DateTime? _dateTime(Object? value) {
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}

@override
Map<String, dynamic> toJson() => {
'label': label,
Expand Down
14 changes: 13 additions & 1 deletion lib/src/models/user_profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String, dynamic> toJson() => {
'uid': uid,
'displayName': displayName,
Expand Down
32 changes: 30 additions & 2 deletions lib/src/models/user_role.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserRole> {
List<int> get visibleTabIndices {
final tabs = <int>{};
Expand All @@ -44,9 +55,26 @@ extension UserRoleSetPermissions on Set<UserRole> {
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();
Expand Down
9 changes: 7 additions & 2 deletions lib/src/services/desktop_auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ class DesktopAuthService implements SpectrumAuthService {

@override
Future<void> 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(
Expand All @@ -75,7 +76,11 @@ class DesktopAuthService implements SpectrumAuthService {
} else {
await prefs.remove(_prefsKey);
}
} catch (_) {}
} catch (_) {
try {
await prefs?.remove(_prefsKey);
} catch (_) {}
}
}

@override
Expand Down
Loading