diff --git a/.pr_agent.toml b/.pr_agent.toml new file mode 100644 index 0000000..dbf2e8f --- /dev/null +++ b/.pr_agent.toml @@ -0,0 +1,72 @@ +[config] +# https://openrouter.ai/openrouter/free, reached through LiteLLM's openrouter/ +# prefix, so the id doubles up: openrouter/ + openrouter/free. +# +# This is a router over whatever is free right now, not a fixed model, which is +# the point. Pinned free ids rotate out: the two this file was first written +# against (qwen-2.5-coder-32b, deepseek-chat-v3) were already gone by the time +# it landed. The router keeps resolving without anyone editing this line. +model = "openrouter/openrouter/free" +# The router alias isn't in PR-Agent's built-in MAX_TOKENS table, so without +# this it refuses to call the model at all ("not defined in MAX_TOKENS and no +# custom_model_max_tokens is set"). 32000 matches this repo's PR sizes with +# room to spare; found by actually running a review and reading the failure, +# not from the docs. +custom_model_max_tokens = 32000 +# PR-Agent's own default fallback is gpt-5.6-terra over a direct OpenAI call, +# which always fails here (no OPENAI_KEY secret, so it authenticates with a +# literal "dummy_key"). Fall back to the same OpenRouter router instead, since +# a second call may land on a different backing model than the first. +fallback_models = ["openrouter/openrouter/free"] + +enable_auto_review = true +enable_auto_describe = false +# Off deliberately: free models are rate limited to roughly 20 requests a minute, +# and code suggestions are the request-hungry tool. Ask for them per PR with +# /improve when a change actually wants them. +enable_auto_improve = false + +ignore_bot_prs = true +require_ready_for_review = true + +[pr_reviewer] +# The repo's own rules, so the review flags what a human reviewer here would. +extra_instructions = """ +This is a Flutter app (Dart) for an FRC team's pit inventory and scouting. +When reviewing: +- No emojis anywhere: code, comments, docs, commit messages, UI strings. + Material Icons glyphs are the way to draw an affordance. +- No AI co-author trailers in commits. +- A top-level dart:ffi import reachable from main.dart breaks the wasm build, + which CI gates. Platform-specific code belongs behind a conditional import, + and the right condition for FFI is dart.library.ffi, not dart.library.io. +- Colors, radii and fonts come from PitPalette and app_theme.dart per + DESIGN.md: 8px corners, 12px for sheets and dialogs, depth as one tonal step + plus a 1px Outline border rather than a shadow, violet accent for action and + selection only, a status hue always paired with a label. Flag hardcoded hex + colors, radii and font families. +- A paragraph-long comment justifying a workaround means the code is wrong. +""" + +[github_action_config] +auto_review = true +auto_describe = false +auto_improve = false + +[ignore] +glob = [ + 'pubspec.lock', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + '**/*.g.dart', + '**/*.freezed.dart', + '**/generated_plugin_registrant.*', + '**/GeneratedPluginRegistrant.*', + 'build/**', + 'ios/Pods/**', + 'macos/Pods/**', + '**/*.min.js', + '**/*.svg', + '**/*.lock', +] diff --git a/README.md b/README.md index a0e2cb4..2a38fb8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,33 @@ FRC pit logistics for Spectrum 3847. One app that keeps the team running at an e Built with Flutter for iOS, Android, and desktop (Windows, macOS, Linux). +## Install + +Builds are attached to [this repo's releases](https://github.com/Spectrum3847/spectrum-pit/releases). They are unsigned, so each platform needs a step or two. + +### iOS (AltStore, SideStore, LiveContainer) + +Open Sources, add a source, and paste one of these URLs: + +- Stable: `https://spectrumpit-stable.web.app/stable.json` +- Nightly: `https://spectrumpit-nightly.web.app/nightly.json` + +Spectrum Pit then shows up as an app you can install, and later builds arrive as updates. The installer re-signs the IPA on device with a free Apple ID, so no paid developer account is needed. Free signing expires after 7 days, so let AltStore or SideStore refresh weekly. SideStore can refresh on device, without a computer. + +The stable source tracks releases; the nightly source rebuilds every night from the latest source. + +### Android + +Download the APK from a release and install it. Android will ask you to allow installs from this source the first time. + +### Desktop + +Each release carries a Linux AppImage, a Windows ZIP, and a zipped macOS `.app`. None are code-signed: + +- Linux: `chmod +x` the AppImage and run it. +- Windows: unzip, then choose "More info" then "Run anyway" at the SmartScreen prompt. +- macOS: unzip, then right-click the app and choose Open, since Gatekeeper blocks a double-click on an unsigned app. + ## About this repository This is the public mirror of Spectrum Pit. The team develops in a private repository; each published release is synced here as a single squashed commit, so this repo always holds the source of the latest release without internal history. diff --git a/lib/main.dart b/lib/main.dart index 65a7ab7..6dfd6ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,9 @@ import 'src/services/desktop_map_diagram_sync_service.dart'; import 'src/services/desktop_map_location_sync_service.dart'; import 'src/services/desktop_packing_sync_service.dart'; import 'src/services/desktop_pit_shift_sync_service.dart'; +import 'src/services/desktop_firestore_cache_stub.dart' + if (dart.library.io) 'src/services/desktop_firestore_cache_io.dart' + as firestore_cache_factory; import 'src/services/desktop_user_role_service.dart'; import 'src/services/inventory_sync_service.dart'; import 'src/services/local_only_services.dart'; @@ -95,12 +98,19 @@ Future main() async { firebaseApiKey: DefaultFirebaseOptions.web.apiKey, launch: (url) => launchUrl(url, mode: LaunchMode.externalApplication), ); + final restFirestore = fc.Firestore( projectId: DefaultFirebaseOptions.web.projectId, idTokenProvider: desktopAuth.idToken, httpClient: TimeoutHttpClient(), + cache: await firestore_cache_factory.createDesktopFirestoreCache( + () => desktopAuth.currentUser?.uid, + ), ); + + desktopAuth.onSessionEnded = + firestore_cache_factory.clearDesktopFirestoreCacheFor; authService = desktopAuth; roleService = DesktopUserRoleService(firestore: restFirestore); inventorySyncService = DesktopInventorySyncService( diff --git a/lib/src/services/desktop_auth_service.dart b/lib/src/services/desktop_auth_service.dart index fd5f394..4387503 100644 --- a/lib/src/services/desktop_auth_service.dart +++ b/lib/src/services/desktop_auth_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'spectrum_auth_service.dart'; @@ -29,6 +30,8 @@ class DesktopAuthService implements SpectrumAuthService { static const String _prefsKey = 'desktop_auth_session_v1'; + Future Function(String uid)? onSessionEnded; + final String clientId; final String firebaseApiKey; @@ -38,6 +41,14 @@ class DesktopAuthService implements SpectrumAuthService { final Future Function() _prefsLoader; late final Future Function() _signInFlow; + StreamSubscription? _authStateSub; + + Future? _listenerSetup; + + bool _endingSession = false; + + Future? _teardown; + final StreamController _controller = StreamController.broadcast(); SpectrumAuthSnapshot _snapshot = const SpectrumAuthSnapshot( @@ -54,18 +65,59 @@ class DesktopAuthService implements SpectrumAuthService { SpectrumUser? get currentUser => _snapshot.user; @override - Future idToken() => _session.getIdToken(); + Future idToken() async { + try { + return await _session.getIdToken(); + } on SocketException { + return null; + } on TimeoutException { + return null; + } on http.ClientException { + return null; + } on HttpException { + return null; + } + } + + Future _ensureAuthStateListener() { + final previous = _listenerSetup; + final next = () async { + await previous; + await _authStateSub?.cancel(); + _authStateSub = _session.authStateChanges.listen((user) { + if (user == null) unawaited(_handleSessionRevoked()); + }); + }(); + _listenerSetup = next; + return next; + } @override Future initialize() async { - SharedPreferences? prefs; + await _ensureAuthStateListener(); try { - prefs = await _prefsLoader(); + final prefs = await _prefsLoader(); final stored = prefs.getString(_prefsKey); if (stored == null) return; - final user = await _session.restore( - (jsonDecode(stored) as Map).cast(), - ); + final Map payload; + try { + payload = (jsonDecode(stored) as Map).cast(); + } catch (_) { + await prefs.remove(_prefsKey); + return; + } + + final uid = payload['uid']; + final refreshToken = payload['refreshToken']; + if (uid is! String || + uid.isEmpty || + refreshToken is! String || + refreshToken.isEmpty) { + await prefs.remove(_prefsKey); + if (uid is String && uid.isNotEmpty) await _endSession(uid); + return; + } + final user = await _session.restore(payload); if (user != null) { _emit( SpectrumAuthSnapshot( @@ -75,16 +127,17 @@ class DesktopAuthService implements SpectrumAuthService { ); } else { await prefs.remove(_prefsKey); + final revokedUid = payload['uid']; + if (revokedUid is String && revokedUid.isNotEmpty) { + await _endSession(revokedUid); + } } - } catch (_) { - try { - await prefs?.remove(_prefsKey); - } catch (_) {} - } + } catch (_) {} } @override Future signIn() async { + await _teardown; _emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signingIn)); try { final tokens = await _signInFlow(); @@ -110,16 +163,53 @@ class DesktopAuthService implements SpectrumAuthService { @override Future signOut() async { + _endingSession = true; + final departingUid = currentUser?.uid; await _session.signOut(); + await _runTeardown(departingUid); + _emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut)); + } + + Future _handleSessionRevoked() async { + if (_endingSession) return; + if (_snapshot.state != SpectrumAuthState.signedIn) return; + final departingUid = currentUser?.uid; + _emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut)); + await _runTeardown(departingUid); + } + + Future _runTeardown(String? uid) { + _endingSession = true; + final done = () async { + try { + await _forgetStoredSession(); + if (uid != null) await _endSession(uid); + } finally { + _endingSession = false; + } + }(); + _teardown = done; + return done; + } + + Future _forgetStoredSession() async { try { final prefs = await _prefsLoader(); await prefs.remove(_prefsKey); } catch (_) {} - _emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut)); + } + + Future _endSession(String uid) async { + try { + await onSessionEnded?.call(uid); + } catch (_) {} } @override Future dispose() async { + await _listenerSetup; + await _authStateSub?.cancel(); + _authStateSub = null; await _controller.close(); _session.close(); } diff --git a/lib/src/services/desktop_firestore_cache_io.dart b/lib/src/services/desktop_firestore_cache_io.dart new file mode 100644 index 0000000..4b3a078 --- /dev/null +++ b/lib/src/services/desktop_firestore_cache_io.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:firestore_client/firestore_client.dart' as fc; +import 'package:path_provider/path_provider.dart'; + +import 'user_scoped_firestore_cache.dart'; + +Future createDesktopFirestoreCache( + String? Function() currentUid, +) async { + final root = await _cacheRoot(); + if (root == null) return null; + return UserScopedFirestoreCache(root: root, currentUid: currentUid); +} + +Future clearDesktopFirestoreCacheFor(String uid) async { + final root = await _cacheRoot(); + if (root == null) return; + await UserScopedFirestoreCache( + root: root, + currentUid: () => uid, + ).clearForUid(uid); +} + +Future _cacheRoot() async { + try { + final support = await getApplicationSupportDirectory(); + return Directory('${support.path}${Platform.pathSeparator}firestore_cache'); + } on Exception { + return null; + } +} diff --git a/lib/src/services/desktop_firestore_cache_stub.dart b/lib/src/services/desktop_firestore_cache_stub.dart new file mode 100644 index 0000000..0ac85ae --- /dev/null +++ b/lib/src/services/desktop_firestore_cache_stub.dart @@ -0,0 +1,7 @@ +import 'package:firestore_client/firestore_client.dart' as fc; + +Future createDesktopFirestoreCache( + String? Function() currentUid, +) async => null; + +Future clearDesktopFirestoreCacheFor(String uid) async {} diff --git a/lib/src/services/desktop_launcher_service.dart b/lib/src/services/desktop_launcher_service.dart index a4dea55..62f2286 100644 --- a/lib/src/services/desktop_launcher_service.dart +++ b/lib/src/services/desktop_launcher_service.dart @@ -46,10 +46,12 @@ class DesktopLauncherService { } static String desktopEntry(String appImagePath, {String? iconPath}) { - final exec = appImagePath.replaceAllMapped( - RegExp(r'["`$\\]'), - (m) => '\\${m[0]}', - ); + final exec = appImagePath.replaceAllMapped(RegExp(r'[%"`$\\]'), (m) { + final char = m[0]!; + if (char == '%') return '%%'; + if (char == r'\') return r'\\\\'; + return '\\\\$char'; + }); final icon = (iconPath ?? 'spectrumpit') .replaceAll('\\', r'\\') .replaceAll('\n', r'\n'); diff --git a/lib/src/services/desktop_update_service.dart b/lib/src/services/desktop_update_service.dart index e2da6aa..bc22bcc 100644 --- a/lib/src/services/desktop_update_service.dart +++ b/lib/src/services/desktop_update_service.dart @@ -50,8 +50,17 @@ class DesktopUpdateService { return null; } + Object? transportFailure; + StackTrace? transportStackTrace; for (final repository in _repositories) { - final release = await _loadLatestRelease(repository); + final _ReleaseSnapshot? release; + try { + release = await _loadLatestRelease(repository); + } catch (error, stackTrace) { + transportFailure ??= error; + transportStackTrace ??= stackTrace; + continue; + } if (release == null) { continue; } @@ -66,43 +75,48 @@ class DesktopUpdateService { ); } } + if (transportFailure != null) { + Error.throwWithStackTrace(transportFailure, transportStackTrace!); + } return null; } 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 Object? decoded; 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 (_) { + decoded = jsonDecode(response.body); + } on FormatException { + return null; + } + 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, + ); } static ({String? url, String? digest}) _appImageAsset(dynamic assets) { diff --git a/lib/src/services/photo_service.dart b/lib/src/services/photo_service.dart index e424061..4ca9159 100644 --- a/lib/src/services/photo_service.dart +++ b/lib/src/services/photo_service.dart @@ -108,6 +108,9 @@ class PhotoService { throw const PhotoException('Storage did not return a photo id.'); } _remember(key, photo.bytes); + + final disk = _diskCache; + if (disk != null) unawaited(disk.write(key, photo.bytes)); return key; } diff --git a/lib/src/services/spectrum_auth_service.dart b/lib/src/services/spectrum_auth_service.dart index ea73cf0..7e6ab21 100644 --- a/lib/src/services/spectrum_auth_service.dart +++ b/lib/src/services/spectrum_auth_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; @@ -68,13 +69,28 @@ class FirebaseSpectrumAuthService implements SpectrumAuthService { SpectrumUser? get currentUser => _snapshot.user; @override - Future idToken() async => _appAuth.currentUser?.getIdToken(); + Future idToken() async { + final user = _appAuth.currentUser; + if (user == null) return null; + try { + return await user.getIdToken(); + } on FirebaseAuthException catch (e) { + if (e.code == 'network-request-failed') return null; + rethrow; + } on SocketException { + return null; + } on TimeoutException { + return null; + } + } @override Future initialize() async { if (!kIsWeb) { await _googleSignIn.initialize(); } + + await _authStateSubscription?.cancel(); _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 229b57c..d619b02 100644 --- a/lib/src/services/synced_map_image_store.dart +++ b/lib/src/services/synced_map_image_store.dart @@ -87,10 +87,15 @@ class SyncedMapImageStore implements MapImageStore { ); var pointerCleared = false; + Object? pointerFailure; + StackTrace? pointerStackTrace; try { await diagramSync.clearKey(mapType); pointerCleared = true; - } catch (_) {} + } catch (error, stackTrace) { + pointerFailure = error; + pointerStackTrace = stackTrace; + } if (pointerCleared && key != null && key.isNotEmpty) { try { await photoService.delete(key); @@ -105,6 +110,9 @@ class SyncedMapImageStore implements MapImageStore { } await prefs.remove(_prefsKey(mapType, _prefsR2Key)); await prefs.remove(_prefsKey(mapType, _prefsFile)); + if (pointerFailure != null) { + Error.throwWithStackTrace(pointerFailure, pointerStackTrace!); + } } Future _cachedFile(MapType mapType) async { diff --git a/lib/src/services/user_scoped_firestore_cache.dart b/lib/src/services/user_scoped_firestore_cache.dart new file mode 100644 index 0000000..7f25a21 --- /dev/null +++ b/lib/src/services/user_scoped_firestore_cache.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:firestore_client/firestore_client.dart' as fc; + +class UserScopedFirestoreCache implements fc.FirestoreCache { + UserScopedFirestoreCache({required this.root, required this.currentUid}); + + final Directory root; + + final String? Function() currentUid; + + static final RegExp _safeUid = RegExp(r'^[A-Za-z0-9_-]{1,64}$'); + + fc.FileFirestoreCache? _cacheFor(String? uid) { + if (uid == null || !_safeUid.hasMatch(uid)) return null; + return fc.FileFirestoreCache( + Directory('${root.path}${Platform.pathSeparator}$uid'), + ); + } + + @override + Future read(String key) async => _cacheFor(currentUid())?.read(key); + + @override + Future write(String key, String value) async => + _cacheFor(currentUid())?.write(key, value); + + @override + Future remove(String key) async => _cacheFor(currentUid())?.remove(key); + + @override + Future clear() async => _cacheFor(currentUid())?.clear(); + + Future clearForUid(String uid) async => _cacheFor(uid)?.clear(); +} diff --git a/lib/src/state/pit_controller_mixin.dart b/lib/src/state/pit_controller_mixin.dart index 86ac54d..8a93ee5 100644 --- a/lib/src/state/pit_controller_mixin.dart +++ b/lib/src/state/pit_controller_mixin.dart @@ -32,6 +32,14 @@ mixin PitControllerMixin on ChangeNotifier { List _pitItems = []; + final Map _pitMutations = {}; + + int _pitBeginMutation(String id) => + _pitMutations[id] = (_pitMutations[id] ?? 0) + 1; + + bool _pitIsNewestMutation(String id, int mutation) => + _pitMutations[id] == mutation; + List get items => List.unmodifiable(_pitItems); Future bootstrap() { @@ -45,6 +53,7 @@ mixin PitControllerMixin on ChangeNotifier { } Future upsert(T item) async { + final mutation = _pitBeginMutation(item.id); final previousIndex = _pitItems.indexWhere((e) => e.id == item.id); final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ @@ -56,6 +65,10 @@ mixin PitControllerMixin on ChangeNotifier { try { await pitUpsertRemote(item); } catch (_) { + if (!_pitIsNewestMutation(item.id, mutation)) { + await _pitSaveCache().catchError((_) {}); + rethrow; + } final restored = [ for (final existing in _pitItems) if (existing.id != item.id) existing, @@ -74,6 +87,7 @@ mixin PitControllerMixin on ChangeNotifier { } Future delete(String id) async { + final mutation = _pitBeginMutation(id); final previousIndex = _pitItems.indexWhere((e) => e.id == id); final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ @@ -84,6 +98,10 @@ mixin PitControllerMixin on ChangeNotifier { try { await pitDeleteRemote(id); } catch (_) { + if (!_pitIsNewestMutation(id, mutation)) { + await _pitSaveCache().catchError((_) {}); + rethrow; + } if (previousItem != null) { final restored = [ for (final existing in _pitItems) diff --git a/lib/src/ui/borrow_tab.dart b/lib/src/ui/borrow_tab.dart index ad1ea3b..7e2a6a5 100644 --- a/lib/src/ui/borrow_tab.dart +++ b/lib/src/ui/borrow_tab.dart @@ -7,6 +7,7 @@ import '../models/borrow_record.dart'; import '../state/borrow_controller.dart'; import '../theme/app_theme.dart'; import '../theme/pit_palette.dart'; +import '../widgets/keyboard_shortcuts.dart'; class BorrowTab extends StatefulWidget { const BorrowTab({required this.controller, super.key}); @@ -245,28 +246,29 @@ class _TeamLabel extends StatelessWidget { @override Widget build(BuildContext context) { final muted = PitPalette.inkMutedOf(context); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Team', - style: Theme.of(context).textTheme.labelLarge?.copyWith(color: muted), - ), - const SizedBox(width: 6), - Text( - number.toString(), - style: pitCodeStyle(context, color: PitPalette.inkOf(context)), - ), - if (name.isNotEmpty) ...[ - const SizedBox(width: 4), - Text( - name, + + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Team ', style: Theme.of( context, - ).textTheme.bodyMedium?.copyWith(color: muted), + ).textTheme.labelLarge?.copyWith(color: muted), + ), + TextSpan( + text: number.toString(), + style: pitCodeStyle(context, color: PitPalette.inkOf(context)), ), + if (name.isNotEmpty) + TextSpan( + text: ' $name', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), + ), ], - ], + ), ); } } @@ -279,16 +281,21 @@ class _CompetitionLabel extends StatelessWidget { @override Widget build(BuildContext context) { final muted = PitPalette.inkMutedOf(context); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.emoji_events_outlined, size: 14, color: muted), - const SizedBox(width: 4), - Text( - name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: muted), - ), - ], + + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(Icons.emoji_events_outlined, size: 14, color: muted), + ), + ), + TextSpan(text: name), + ], + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: muted), ); } } @@ -341,16 +348,21 @@ class _TimestampLabel extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith(color: color), - ), - const SizedBox(width: 6), - Text(value, style: pitCodeStyle(context, color: color)), - ], + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: '$label ', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: color), + ), + TextSpan( + text: value, + style: pitCodeStyle(context, color: color), + ), + ], + ), ); } } @@ -650,108 +662,112 @@ class _BorrowEditorSheetState extends State<_BorrowEditorSheet> { Widget build(BuildContext context) { final editing = widget.record != null; final muted = PitPalette.inkMutedOf(context); - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - editing ? 'Edit loan' : 'Check out tool', - style: Theme.of(context).textTheme.titleLarge, + + return SaveShortcut( + onSave: _save, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + editing ? 'Edit loan' : 'Check out tool', + style: Theme.of(context).textTheme.titleLarge, + ), ), + if (widget.onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _toolName, + autofocus: !editing, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Tool name', + hintText: 'Cordless drill', ), - if (widget.onDelete != null) - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - onPressed: widget.onDelete, - ), - ], - ), - const SizedBox(height: 12), - TextField( - controller: _toolName, - autofocus: !editing, - textCapitalization: TextCapitalization.sentences, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Tool name', - hintText: 'Cordless drill', ), - ), - const SizedBox(height: 12), - TextField( - controller: _teamName, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Team name', - hintText: 'The Cheesy Poofs', + const SizedBox(height: 12), + TextField( + controller: _teamName, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Team name', + hintText: 'The Cheesy Poofs', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _teamNumber, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Team number', - hintText: '254', + const SizedBox(height: 12), + TextField( + controller: _teamNumber, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Team number', + hintText: '254', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _competition, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Competition', - hintText: 'Texas State Championship', + const SizedBox(height: 12), + TextField( + controller: _competition, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Competition', + hintText: 'Texas State Championship', + ), ), - ), - const SizedBox(height: 16), - Text( - 'Checkout time', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: muted), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _pickCheckoutDate, - icon: const Icon(Icons.calendar_today_outlined), - label: Text(_shortDateTime(_checkedOutAt)), - ), - const SizedBox(height: 12), - Text( - 'Estimated return (optional)', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: muted), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _pickEstimatedReturn, - icon: const Icon(Icons.schedule_outlined), - label: Text( - _estimatedReturn != null - ? _shortDateTime(_estimatedReturn!) - : 'Set date', + const SizedBox(height: 16), + Text( + 'Checkout time', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: muted), ), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: _toolName.text.trim().isEmpty ? null : _save, - child: Text(editing ? 'Save' : 'Check out'), - ), - ], + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _pickCheckoutDate, + icon: const Icon(Icons.calendar_today_outlined), + label: Text(_shortDateTime(_checkedOutAt)), + ), + const SizedBox(height: 12), + Text( + 'Estimated return (optional)', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: muted), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _pickEstimatedReturn, + icon: const Icon(Icons.schedule_outlined), + label: Text( + _estimatedReturn != null + ? _shortDateTime(_estimatedReturn!) + : 'Set date', + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _toolName.text.trim().isEmpty ? null : _save, + child: Text(editing ? 'Save' : 'Check out'), + ), + ], + ), ), ), ); diff --git a/lib/src/ui/inventory_tab.dart b/lib/src/ui/inventory_tab.dart index 3d925ba..a31c0e3 100644 --- a/lib/src/ui/inventory_tab.dart +++ b/lib/src/ui/inventory_tab.dart @@ -7,6 +7,7 @@ import '../models/inventory_item.dart'; import '../state/inventory_controller.dart'; import '../theme/pit_palette.dart'; import 'location_code.dart'; +import '../widgets/keyboard_shortcuts.dart'; class InventoryTab extends StatefulWidget { const InventoryTab({required this.controller, super.key}); @@ -501,96 +502,100 @@ class _ItemEditorSheetState extends State<_ItemEditorSheet> { @override Widget build(BuildContext context) { final editing = widget.item != null; - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - editing ? 'Edit tool' : 'Add tool', - style: Theme.of(context).textTheme.titleLarge, + + return SaveShortcut( + onSave: _save, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + editing ? 'Edit tool' : 'Add tool', + style: Theme.of(context).textTheme.titleLarge, + ), ), + if (widget.onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _name, + autofocus: !editing, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'Cordless drill', ), - if (widget.onDelete != null) - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - onPressed: widget.onDelete, - ), - ], - ), - const SizedBox(height: 12), - TextField( - controller: _name, - autofocus: !editing, - textCapitalization: TextCapitalization.sentences, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Name', - hintText: 'Cordless drill', ), - ), - const SizedBox(height: 12), - TextField( - controller: _lab, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: 'Lab location', - hintText: 'RC1-DB', + const SizedBox(height: 12), + TextField( + controller: _lab, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: 'Lab location', + hintText: 'RC1-DB', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _pit, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: 'Pit location', - hintText: 'CAB-A2', + const SizedBox(height: 12), + TextField( + controller: _pit, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: 'Pit location', + hintText: 'CAB-A2', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _mapRef, - decoration: const InputDecoration( - labelText: 'Map reference (optional)', + const SizedBox(height: 12), + TextField( + controller: _mapRef, + decoration: const InputDecoration( + labelText: 'Map reference (optional)', + ), ), - ), - const SizedBox(height: 16), - Text( - 'Status', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: PitPalette.inkMutedOf(context), + const SizedBox(height: 16), + Text( + 'Status', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: PitPalette.inkMutedOf(context), + ), ), - ), - const SizedBox(height: 8), - SegmentedButton( - segments: [ - for (final status in InventoryStatus.values) - ButtonSegment( - value: status, - label: Text(_statusLabel(status)), - icon: Icon(_statusIcon(status)), - ), - ], - selected: {_status}, - onSelectionChanged: (s) => setState(() => _status = s.first), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: _name.text.trim().isEmpty ? null : _save, - child: Text(editing ? 'Save' : 'Add tool'), - ), - ], + const SizedBox(height: 8), + SegmentedButton( + segments: [ + for (final status in InventoryStatus.values) + ButtonSegment( + value: status, + label: Text(_statusLabel(status)), + icon: Icon(_statusIcon(status)), + ), + ], + selected: {_status}, + onSelectionChanged: (s) => setState(() => _status = s.first), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _name.text.trim().isEmpty ? null : _save, + child: Text(editing ? 'Save' : 'Add tool'), + ), + ], + ), ), ), ); diff --git a/lib/src/ui/location_code.dart b/lib/src/ui/location_code.dart index 476f241..ced2ec4 100644 --- a/lib/src/ui/location_code.dart +++ b/lib/src/ui/location_code.dart @@ -21,11 +21,14 @@ class LocationCode extends StatelessWidget { style: Theme.of(context).textTheme.labelLarge?.copyWith(color: muted), ), const SizedBox(width: 6), - Text( - value.isEmpty ? '--' : value.toUpperCase(), - style: pitCodeStyle( - context, - color: value.isEmpty ? muted : PitPalette.inkOf(context), + + Flexible( + child: Text( + value.isEmpty ? '--' : value.toUpperCase(), + style: pitCodeStyle( + context, + color: value.isEmpty ? muted : PitPalette.inkOf(context), + ), ), ), ], diff --git a/lib/src/ui/maps_tab.dart b/lib/src/ui/maps_tab.dart index f8b0d6c..bf5109b 100644 --- a/lib/src/ui/maps_tab.dart +++ b/lib/src/ui/maps_tab.dart @@ -11,6 +11,7 @@ import '../state/inventory_controller.dart'; import '../state/map_location_controller.dart'; import '../theme/pit_palette.dart'; import 'location_code.dart'; +import '../widgets/keyboard_shortcuts.dart'; class MapsTab extends StatefulWidget { const MapsTab({ @@ -367,7 +368,15 @@ class _MapsTabState extends State { return Stack( children: [ Positioned.fill( - child: Image(image: diagram.image, fit: BoxFit.contain), + child: Semantics( + image: true, + label: pins.isEmpty + ? '${_mapLabel(_mapType)} diagram, no locations marked' + : '${_mapLabel(_mapType)} diagram, ' + '${pins.length} location${pins.length == 1 ? '' : 's'} ' + 'marked', + child: Image(image: diagram.image, fit: BoxFit.contain), + ), ), for (final pin in pins) _buildPin(pin, dest), ], @@ -390,41 +399,51 @@ class _MapsTabState extends State { return Positioned( left: center.dx - _pinSize / 2 - _pinTouchInset, top: center.dy - _pinSize - _pinTouchInset, - child: GestureDetector( - behavior: HitTestBehavior.opaque, + + child: Semantics( + button: true, + label: pin.inventoryItemId != null + ? '${pin.name}, linked to a tool' + : '${pin.name}, not linked to a tool', onTap: () => _openPinDetail(pin), - onPanUpdate: (details) { - if (dest.isEmpty) return; - final current = _dragPositions[pin.id] ?? Offset(pin.x, pin.y); - setState(() { - _dragPositions[pin.id] = Offset( - (current.dx + details.delta.dx / dest.width) - .clamp(0.0, 1.0) - .toDouble(), - (current.dy + details.delta.dy / dest.height) - .clamp(0.0, 1.0) - .toDouble(), - ); - }); - }, - onPanEnd: (_) { - final fraction = _dragPositions[pin.id]; - _dragPositions.remove(pin.id); - if (fraction != null) _movePin(pin, fraction.dx, fraction.dy); - }, - child: SizedBox( - width: _pinTouchSize, - height: _pinTouchSize, - child: Center( - child: _PinGlyph( - linked: pin.inventoryItemId != null, - size: _pinSize, + child: GestureDetector( + 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( + (current.dx + details.delta.dx / dest.width) + .clamp(0.0, 1.0) + .toDouble(), + (current.dy + details.delta.dy / dest.height) + .clamp(0.0, 1.0) + .toDouble(), + ); + }); + }, + onPanEnd: (_) { + final fraction = _dragPositions[pin.id]; + _dragPositions.remove(pin.id); + if (fraction != null) _movePin(pin, fraction.dx, fraction.dy); + }, + child: SizedBox( + width: _pinTouchSize, + height: _pinTouchSize, + child: Center( + child: _PinGlyph( + linked: pin.inventoryItemId != null, + size: _pinSize, + ), ), ), ), ), ); } + + static String _mapLabel(MapType type) => type == MapType.lab ? 'Lab' : 'Pit'; } class _PinGlyph extends StatelessWidget { @@ -667,50 +686,54 @@ class _MapPinEditorSheetState extends State<_MapPinEditorSheet> { @override Widget build(BuildContext context) { final editing = widget.pin != null; - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - editing ? 'Edit pin' : 'Add pin', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 12), - TextField( - controller: _name, - autofocus: !editing, - textCapitalization: TextCapitalization.sentences, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Name', - hintText: 'Battery cart', + + return SaveShortcut( + onSave: _save, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + editing ? 'Edit pin' : 'Add pin', + style: Theme.of(context).textTheme.titleLarge, ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _linkedItemId, - decoration: const InputDecoration(labelText: 'Linked tool'), - items: [ - const DropdownMenuItem(value: null, child: Text('None')), - for (final item in widget.inventoryItems) - DropdownMenuItem(value: item.id, child: Text(item.name)), - ], - onChanged: (value) => setState(() => _linkedItemId = value), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: _name.text.trim().isEmpty ? null : _save, - child: Text(editing ? 'Save' : 'Add pin'), - ), - ], + const SizedBox(height: 12), + TextField( + controller: _name, + autofocus: !editing, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'Battery cart', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _linkedItemId, + decoration: const InputDecoration(labelText: 'Linked tool'), + items: [ + const DropdownMenuItem(value: null, child: Text('None')), + for (final item in widget.inventoryItems) + DropdownMenuItem(value: item.id, child: Text(item.name)), + ], + onChanged: (value) => setState(() => _linkedItemId = value), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _name.text.trim().isEmpty ? null : _save, + child: Text(editing ? 'Save' : 'Add pin'), + ), + ], + ), ), ), ); diff --git a/lib/src/ui/packing_tab.dart b/lib/src/ui/packing_tab.dart index 54b9fa1..6fb59a4 100644 --- a/lib/src/ui/packing_tab.dart +++ b/lib/src/ui/packing_tab.dart @@ -9,6 +9,7 @@ import '../services/photo_service.dart'; import '../state/packing_controller.dart'; import '../theme/pit_palette.dart'; import 'packing_photo.dart'; +import '../widgets/keyboard_shortcuts.dart'; class PackingTab extends StatefulWidget { const PackingTab({ @@ -207,6 +208,8 @@ class _PackingTabState extends State { if (deleted && photoRef != null) { await _deleteKey(photoRef, record.itemId); } + + if (!deleted) return; if (sheetContext.mounted) Navigator.of(sheetContext).pop(); }, ), @@ -864,83 +867,87 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { @override Widget build(BuildContext context) { final editing = widget.record != null; - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - editing ? 'Edit packing item' : 'Add packing item', - style: Theme.of(context).textTheme.titleLarge, + + return SaveShortcut( + onSave: _save, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + editing ? 'Edit packing item' : 'Add packing item', + style: Theme.of(context).textTheme.titleLarge, + ), ), + if (widget.onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _itemId, + autofocus: !editing, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Item name', + hintText: 'DeWalt Drill Kit', ), - if (widget.onDelete != null) - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - onPressed: widget.onDelete, - ), - ], - ), - const SizedBox(height: 12), - TextField( - controller: _itemId, - autofocus: !editing, - textCapitalization: TextCapitalization.sentences, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Item name', - hintText: 'DeWalt Drill Kit', ), - ), - const SizedBox(height: 16), - _PhotoSection( - photoRef: _photoRef, - photoService: widget.photoService, - busy: _busy, - size: _photoSize, - onTap: _capturePhoto, - ), - const SizedBox(height: 16), - Text( - 'Packing status', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: PitPalette.inkMutedOf(context), + const SizedBox(height: 16), + _PhotoSection( + photoRef: _photoRef, + photoService: widget.photoService, + busy: _busy, + size: _photoSize, + onTap: _capturePhoto, ), - ), - const SizedBox(height: 8), - SegmentedButton( - segments: [ - for (final status in PackingStatus.values) - ButtonSegment( - value: status, - - label: FittedBox( - fit: BoxFit.scaleDown, - child: Text(_statusLabel(status), softWrap: false), + const SizedBox(height: 16), + Text( + 'Packing status', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: PitPalette.inkMutedOf(context), + ), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: [ + for (final status in PackingStatus.values) + ButtonSegment( + value: status, + + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(_statusLabel(status), softWrap: false), + ), + icon: Icon(_statusIcon(status)), ), - icon: Icon(_statusIcon(status)), - ), - ], - selected: {_status}, - onSelectionChanged: (s) => setState(() => _status = s.first), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: _itemId.text.trim().isEmpty ? null : _save, - child: Text(editing ? 'Save' : 'Add item'), - ), - ], + ], + selected: {_status}, + onSelectionChanged: (s) => setState(() => _status = s.first), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _itemId.text.trim().isEmpty ? null : _save, + child: Text(editing ? 'Save' : 'Add item'), + ), + ], + ), ), ), ); diff --git a/lib/src/ui/schedule_tab.dart b/lib/src/ui/schedule_tab.dart index 5282b6e..73582d1 100644 --- a/lib/src/ui/schedule_tab.dart +++ b/lib/src/ui/schedule_tab.dart @@ -11,6 +11,7 @@ import '../state/pit_shift_controller.dart'; import '../state/user_role_controller.dart'; import '../theme/app_theme.dart'; import '../theme/pit_palette.dart'; +import '../widgets/keyboard_shortcuts.dart'; class ScheduleTab extends StatefulWidget { const ScheduleTab({ @@ -884,206 +885,210 @@ class _ShiftEditorSheetState extends State<_ShiftEditorSheet> { : editing ? 'Edit shift' : 'Add shift'; - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, + + return SaveShortcut( + onSave: _save, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), ), + if (widget.onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + onPressed: widget.onDelete, + ), + ], + ), + if (_selfOnly) ...[ + const SizedBox(height: 4), + Text( + 'Nobody else is changed. The crew sees this time as yours, and ' + 'anything scheduled over it is flagged.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), ), - if (widget.onDelete != null) - IconButton( - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - onPressed: widget.onDelete, - ), ], - ), - if (_selfOnly) ...[ - const SizedBox(height: 4), + const SizedBox(height: 12), + TextField( + controller: _label, + autofocus: !editing, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: _selfOnly ? 'Reason' : 'Shift name', + hintText: _selfOnly ? 'Driving home' : 'Pit duty, qual block', + ), + ), + if (!_selfOnly) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _kind, + decoration: const InputDecoration(labelText: 'Type'), + items: [ + for (final kind in ShiftKind.values) + if (kind != ShiftKind.unavailable) + DropdownMenuItem( + value: kind, + child: Text(_kindLabel(kind)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _kind = value); + }, + ), + ], + const SizedBox(height: 12), + TextField( + controller: _competition, + textCapitalization: TextCapitalization.words, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Competition', + hintText: 'Texas State Championship', + ), + ), + const SizedBox(height: 16), Text( - 'Nobody else is changed. The crew sees this time as yours, and ' - 'anything scheduled over it is flagged.', + 'Scheduled by', style: Theme.of( context, - ).textTheme.bodyMedium?.copyWith(color: muted), - ), - ], - const SizedBox(height: 12), - TextField( - controller: _label, - autofocus: !editing, - textCapitalization: TextCapitalization.sentences, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _selfOnly ? 'Reason' : 'Shift name', - hintText: _selfOnly ? 'Driving home' : 'Pit duty, qual block', + ).textTheme.labelLarge?.copyWith(color: muted), ), - ), - if (!_selfOnly) ...[ - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _kind, - decoration: const InputDecoration(labelText: 'Type'), - items: [ - for (final kind in ShiftKind.values) - if (kind != ShiftKind.unavailable) - DropdownMenuItem( - value: kind, - child: Text(_kindLabel(kind)), - ), + const SizedBox(height: 8), + SegmentedButton<_RangeMode>( + segments: const [ + ButtonSegment( + value: _RangeMode.match, + label: Text('Match'), + icon: Icon(Icons.sports_score_outlined), + ), + ButtonSegment( + value: _RangeMode.time, + label: Text('Time'), + icon: Icon(Icons.schedule_outlined), + ), ], - onChanged: (value) { - if (value != null) setState(() => _kind = value); - }, + selected: {_mode}, + onSelectionChanged: (s) => setState(() => _mode = s.first), ), - ], - const SizedBox(height: 12), - TextField( - controller: _competition, - textCapitalization: TextCapitalization.words, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Competition', - hintText: 'Texas State Championship', + const SizedBox(height: 4), + Text( + _mode == _RangeMode.match + ? 'Match numbers only. Clock times are ignored.' + : 'Clock times only. Match numbers are ignored.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), ), - ), - const SizedBox(height: 16), - Text( - 'Scheduled by', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: muted), - ), - const SizedBox(height: 8), - SegmentedButton<_RangeMode>( - segments: const [ - ButtonSegment( - value: _RangeMode.match, - label: Text('Match'), - icon: Icon(Icons.sports_score_outlined), - ), - ButtonSegment( - value: _RangeMode.time, - label: Text('Time'), - icon: Icon(Icons.schedule_outlined), - ), - ], - selected: {_mode}, - onSelectionChanged: (s) => setState(() => _mode = s.first), - ), - const SizedBox(height: 4), - Text( - _mode == _RangeMode.match - ? 'Match numbers only. Clock times are ignored.' - : 'Clock times only. Match numbers are ignored.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: muted), - ), - const SizedBox(height: 12), - if (_mode == _RangeMode.match) - Row( - children: [ - Expanded( - child: TextField( - controller: _startMatch, - keyboardType: TextInputType.number, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'First match', - hintText: '18', + const SizedBox(height: 12), + if (_mode == _RangeMode.match) + Row( + children: [ + Expanded( + child: TextField( + controller: _startMatch, + keyboardType: TextInputType.number, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'First match', + hintText: '18', + ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _endMatch, - keyboardType: TextInputType.number, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Last match', - hintText: '34', + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _endMatch, + keyboardType: TextInputType.number, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Last match', + hintText: '34', + ), ), ), - ), - ], - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton.icon( - onPressed: () => _pickBound(start: true), - icon: const Icon(Icons.play_arrow_outlined), - label: Text( - _startsAt == null - ? 'Set start' - : 'Starts ${_shortDateTime(_startsAt!.toLocal())}', + ], + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: () => _pickBound(start: true), + icon: const Icon(Icons.play_arrow_outlined), + label: Text( + _startsAt == null + ? 'Set start' + : 'Starts ${_shortDateTime(_startsAt!.toLocal())}', + ), ), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () => _pickBound(start: false), - icon: const Icon(Icons.stop_outlined), - label: Text( - _endsAt == null - ? 'Set end' - : 'Ends ${_shortDateTime(_endsAt!.toLocal())}', + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _pickBound(start: false), + icon: const Icon(Icons.stop_outlined), + label: Text( + _endsAt == null + ? 'Set end' + : 'Ends ${_shortDateTime(_endsAt!.toLocal())}', + ), ), - ), - ], - ), - if (!_selfOnly) ...[ - const SizedBox(height: 16), - Text( - 'Assigned to', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: muted), + ], + ), + if (!_selfOnly) ...[ + const SizedBox(height: 16), + Text( + 'Assigned to', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: muted), + ), + const SizedBox(height: 8), + _AssigneePicker( + known: widget.knownAssignees, + rosterStream: widget.rosterStream, + selected: _selected, + onChanged: (next) => setState(() => _selected = next), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _notes, + textCapitalization: TextCapitalization.sentences, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + hintText: 'Bring the spare battery cart', + ), ), - const SizedBox(height: 8), - _AssigneePicker( - known: widget.knownAssignees, - rosterStream: widget.rosterStream, - selected: _selected, - onChanged: (next) => setState(() => _selected = next), + const SizedBox(height: 20), + FilledButton( + onPressed: _canSave ? _save : null, + child: Text( + _selfOnly + ? (editing ? 'Save' : 'Mark unavailable') + : (editing ? 'Save' : 'Add shift'), + ), ), ], - const SizedBox(height: 12), - TextField( - controller: _notes, - textCapitalization: TextCapitalization.sentences, - maxLines: 2, - decoration: const InputDecoration( - labelText: 'Notes (optional)', - hintText: 'Bring the spare battery cart', - ), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: _canSave ? _save : null, - child: Text( - _selfOnly - ? (editing ? 'Save' : 'Mark unavailable') - : (editing ? 'Save' : 'Add shift'), - ), - ), - ], + ), ), ), ); diff --git a/lib/src/widgets/keyboard_shortcuts.dart b/lib/src/widgets/keyboard_shortcuts.dart new file mode 100644 index 0000000..6cd11b8 --- /dev/null +++ b/lib/src/widgets/keyboard_shortcuts.dart @@ -0,0 +1,51 @@ +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SaveShortcut extends StatelessWidget { + const SaveShortcut({required this.onSave, required this.child, super.key}); + + final VoidCallback? onSave; + + final Widget child; + + @override + Widget build(BuildContext context) { + final VoidCallback? onSave = this.onSave; + if (onSave == null) return child; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyS, control: true): onSave, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true): onSave, + }, + child: child, + ); + } +} + +class HorizontalStepShortcuts extends StatelessWidget { + const HorizontalStepShortcuts({ + required this.onPrevious, + required this.onNext, + required this.child, + super.key, + }); + + final VoidCallback onPrevious; + final VoidCallback onNext; + final Widget child; + + @override + Widget build(BuildContext context) { + return FocusTraversalGroup( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowLeft): onPrevious, + const SingleActivator(LogicalKeyboardKey.arrowRight): onNext, + }, + child: child, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 97875bc..cebd099 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -309,11 +309,11 @@ packages: dependency: "direct main" description: path: "." - ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b - resolved-ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b + ref: "v0.2.0" + resolved-ref: "0fb59bd8c569b102a341478c2a1c25565cdd4308" url: "https://github.com/Project516/firestore_client.git" source: git - version: "0.1.0" + version: "0.2.0" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3230535..94bac44 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.1.0+4 +version: 1.2.0+5 environment: sdk: ^3.11.5 @@ -43,7 +43,7 @@ dependencies: firestore_client: git: url: https://github.com/Project516/firestore_client.git - ref: e70bf7e60dc27b352b1c89e0abb3a6892c3cbc2b + ref: v0.2.0 shared_preferences: ^2.5.5 uuid: ^4.6.0 package_info_plus: ^10.2.1 diff --git a/test/borrow_tab_test.dart b/test/borrow_tab_test.dart index 0ed3f7d..2b33e4b 100644 --- a/test/borrow_tab_test.dart +++ b/test/borrow_tab_test.dart @@ -75,7 +75,9 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Drill'), findsOneWidget); - expect(find.text('254'), findsOneWidget); + // The team line is one rich paragraph so it can wrap at large text + // settings (#193), so the number lives in a span rather than its own Text. + expect(find.textContaining('254', findRichText: true), findsOneWidget); }); testWidgets('active loan shows Check in button', (tester) async { diff --git a/test/desktop_auth_service_test.dart b/test/desktop_auth_service_test.dart index 9b805d6..62eec65 100644 --- a/test/desktop_auth_service_test.dart +++ b/test/desktop_auth_service_test.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:firestore_client/firestore_client.dart' as fc; import 'package:flutter_test/flutter_test.dart'; @@ -146,6 +148,231 @@ void main() { expect(prefs.getString('desktop_auth_session_v1'), isNull); }); + test('signOut drops the data scoped to the user who left', () async { + final service = _service(); + final ended = []; + service.onSessionEnded = (uid) async => ended.add(uid); + await service.signIn(); + await service.signOut(); + + expect(ended, ['uid-9']); + }); + + test('signOut completes when clearing the cached data fails', () async { + final service = _service(); + service.onSessionEnded = (_) async => + throw const FileSystemException('locked'); + await service.signIn(); + await service.signOut(); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNull); + }); + + test('a revoked session drops the data cached for it', () async { + SharedPreferences.setMockInitialValues({ + 'desktop_auth_session_v1': jsonEncode({ + 'uid': 'uid-gone', + 'refreshToken': 'dead', + }), + }); + final service = _service(refreshStatus: 400); + final ended = []; + service.onSessionEnded = (uid) async => ended.add(uid); + await service.initialize(); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + expect(ended, ['uid-gone']); + }); + + // A payload that decodes but carries the wrong type throws inside restore(), + // which is not a network failure. Kept, it would throw on every later launch. + test('initialize drops a payload with a wrong-typed refresh token', () async { + SharedPreferences.setMockInitialValues({ + 'desktop_auth_session_v1': jsonEncode({ + 'uid': 'uid-9', + 'refreshToken': 12345, + }), + }); + final service = _service(); + final ended = []; + service.onSessionEnded = (uid) async => ended.add(uid); + await service.initialize(); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNull); + // The uid is still readable, so that user's cached documents go too. + expect(ended, ['uid-9']); + }); + + // Spectrum3847/SpectrumStrategy#824: the token endpoint being unreachable is + // not the refresh token being refused, so the stored session has to survive a + // launch with no network. firestore_client 0.2.0 resolves the persisted user, + // so the app comes up signed in and reads come from the offline cache. + test('initialize stays signed in when the network fails', () async { + SharedPreferences.setMockInitialValues({ + 'desktop_auth_session_v1': jsonEncode({ + 'uid': 'uid-9', + 'refreshToken': 'refresh-1', + }), + }); + final service = DesktopAuthService( + clientId: 'client-123', + firebaseApiKey: 'fake-key', + session: fc.FirebaseAuthSession( + apiKey: 'fake-key', + httpClient: MockClient( + (_) async => throw const SocketException('No route to host'), + ), + ), + signInFlow: () async => const fc.GoogleTokens(idToken: 'google-id-token'), + ); + addTearDown(service.dispose); + await service.initialize(); + + expect(service.snapshot.state, SpectrumAuthState.signedIn); + expect(service.currentUser?.uid, 'uid-9'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNotNull); + // Null rather than a thrown SocketException: the photo Worker reads this + // and treats a null token as nothing to send, so an offline launch has no + // token to attach instead of an error to surface. + expect(await service.idToken(), isNull); + }); + + // A payload that cannot be decoded would throw on every later launch, so it + // is the one failure that does clear the key. + test('initialize drops a corrupt stored payload', () async { + SharedPreferences.setMockInitialValues({ + 'desktop_auth_session_v1': 'not json at all', + }); + final service = _service(); + await service.initialize(); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNull); + }); + + // A refresh refused mid-session ends the session inside firestore_client. Only + // the restore path used to notice, so the app kept looking signed in with the + // stored payload and that user's cached documents on disk until the next + // launch (#204). + test('a refresh refused mid-session signs the app out', () async { + var now = DateTime.utc(2026, 1, 1, 12); + final endedFor = []; + final service = DesktopAuthService( + clientId: 'client-123', + firebaseApiKey: 'fake-key', + session: fc.FirebaseAuthSession( + apiKey: 'fake-key', + httpClient: _firebaseBackend(refreshStatus: 403), + clock: () => now, + ), + signInFlow: () async => const fc.GoogleTokens(idToken: 'google-id-token'), + ); + addTearDown(service.dispose); + service.onSessionEnded = (uid) async => endedFor.add(uid); + + await service.initialize(); + await service.signIn(); + expect(service.snapshot.state, SpectrumAuthState.signedIn); + + // Walk past the token's hour so the next read refreshes rather than + // returning the cached one, and the mocked endpoint refuses it. + now = now.add(const Duration(hours: 2)); + expect(await service.idToken(), isNull); + // authStateChanges is a broadcast stream, so the teardown it triggers lands + // a microtask later. + await Future.delayed(Duration.zero); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + expect(endedFor, ['uid-9']); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNull); + }); + + // The deliberate sign-out and the revoked-token listener both tear the session + // down, and firestore_client's signOut() emits on authStateChanges, so without + // a guard one sign-out would run the cleanup twice. + test('signOut tears the session down exactly once', () async { + final endedFor = []; + final service = _service(); + service.onSessionEnded = (uid) async => endedFor.add(uid); + + await service.initialize(); + await service.signIn(); + await service.signOut(); + await Future.delayed(Duration.zero); + + expect(service.snapshot.state, SpectrumAuthState.signedOut); + expect(endedFor, ['uid-9']); + }); + + // The bootstrap retry calls initialize() again, and two overlapping calls could + // both pass the cancel before either assigned. The losing subscription would + // stay live with nothing holding it, so dispose() could not cancel it and a + // revoked token would run the teardown twice. + test('overlapping initialize calls leave one live subscription', () async { + final endedFor = []; + var now = DateTime.utc(2026, 1, 1, 12); + final service = DesktopAuthService( + clientId: 'client-123', + firebaseApiKey: 'fake-key', + session: fc.FirebaseAuthSession( + apiKey: 'fake-key', + httpClient: _firebaseBackend(refreshStatus: 403), + clock: () => now, + ), + signInFlow: () async => const fc.GoogleTokens(idToken: 'google-id-token'), + ); + addTearDown(service.dispose); + service.onSessionEnded = (uid) async => endedFor.add(uid); + + await Future.wait(>[ + service.initialize(), + service.initialize(), + service.initialize(), + ]); + await service.signIn(); + + now = now.add(const Duration(hours: 2)); + expect(await service.idToken(), isNull); + await Future.delayed(Duration.zero); + + // One teardown, not one per initialize() call. + expect(endedFor, ['uid-9']); + }); + + // Teardown clears the cache through a caller-supplied callback, which can take + // as long as it likes. A user who signs in again during that window used to + // have their brand-new stored session deleted by the teardown that was still + // running, so the next launch came up signed out. + test('signing in during a teardown keeps the new session', () async { + final releaseCleanup = Completer(); + final service = _service(); + service.onSessionEnded = (_) => releaseCleanup.future; + + await service.initialize(); + await service.signIn(); + + // Start the sign-out but leave its cache cleanup hanging. + final signOut = service.signOut(); + await Future.delayed(Duration.zero); + + // The user signs back in while that cleanup is still in flight. + final signIn = service.signIn(); + await Future.delayed(Duration.zero); + releaseCleanup.complete(); + await Future.wait(>[signOut, signIn]); + + expect(service.snapshot.state, SpectrumAuthState.signedIn); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('desktop_auth_session_v1'), isNotNull); + }); + test('a failed sign-in flow emits a friendly error', () async { final service = DesktopAuthService( clientId: 'client-123', diff --git a/test/desktop_launcher_service_test.dart b/test/desktop_launcher_service_test.dart index 0edd839..5a06c33 100644 --- a/test/desktop_launcher_service_test.dart +++ b/test/desktop_launcher_service_test.dart @@ -91,4 +91,32 @@ void main() { await expectLater(service.registerInLauncher(), throwsStateError); }); + + // #202: quoting the Exec path is not enough on its own. A path holding a $ or + // a backtick is reinterpreted by the launcher, and a literal % is read as the + // start of a field code. + test('desktopEntry escapes reserved characters in the Exec path', () { + final entry = DesktopLauncherService.desktopEntry( + r'/home/a$b/`c`/d"e"/f\g/App.AppImage', + ); + + // Each reserved character takes two backslashes, because the entry's string + // escaping is undone before the Exec quoting is. The embedded quotes matter + // most: an unescaped `"` would close the Exec value early and the launcher + // would read the rest of the path as separate arguments. + expect( + entry, + contains(r'Exec="/home/a\\$b/\\`c\\`/d\\"e\\"/f\\\\g/App.AppImage" %U'), + ); + // %U is a field code the launcher must still expand, so it stays bare. + expect(entry, contains(' %U')); + }); + + test('desktopEntry doubles a literal percent in the Exec path', () { + final entry = DesktopLauncherService.desktopEntry( + '/home/u/100% Done/App.AppImage', + ); + + expect(entry, contains('Exec="/home/u/100%% Done/App.AppImage" %U')); + }); } diff --git a/test/desktop_update_service_test.dart b/test/desktop_update_service_test.dart index 8003ca4..9f0594b 100644 --- a/test/desktop_update_service_test.dart +++ b/test/desktop_update_service_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -122,4 +123,46 @@ void main() { expect(info.repository, 'owner/fallback'); }, ); + + test('a network failure is not silently reported as up to date', () async { + // The settings screen renders a null result as "you are on the latest + // version", so swallowing the transport error told a user on a dropped + // network that they were current (#184). + final service = DesktopUpdateService( + client: MockClient((request) async { + throw const SocketException('no route to host'); + }), + currentVersionLoader: () async => '1.1.0', + ); + + await expectLater( + service.checkForUpdate(), + throwsA(isA()), + ); + }); + + test('one unreachable repository does not hide a newer one', () async { + final client = MockClient((request) async { + if (request.url.path.contains('owner/primary')) { + throw const SocketException('no route to host'); + } + return http.Response( + jsonEncode({ + 'tag_name': 'v2.0.0', + 'html_url': 'https://example.com/releases/v2.0.0', + }), + 200, + ); + }); + final service = DesktopUpdateService( + client: client, + currentVersionLoader: () async => '1.5.0', + repositories: const ['owner/primary', 'owner/fallback'], + ); + + final info = await service.checkForUpdate(); + + expect(info, isNotNull); + expect(info!.repository, 'owner/fallback'); + }); } diff --git a/test/keyboard_shortcuts_test.dart b/test/keyboard_shortcuts_test.dart new file mode 100644 index 0000000..9bd34fa --- /dev/null +++ b/test/keyboard_shortcuts_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:spectrumpit/src/widgets/keyboard_shortcuts.dart'; + +/// #197: the desktop build had no keyboard path at all, which all four platform +/// guidelines require. These cover the shortcut widgets plus the Escape +/// behaviour that comes free, so the assumption is on record. +Future _pump(WidgetTester tester, Widget child) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('Ctrl+S and Cmd+S both save', (tester) async { + var saves = 0; + await _pump( + tester, + SaveShortcut( + onSave: () => saves++, + child: const TextField(autofocus: true), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.sendKeyDownEvent(LogicalKeyboardKey.meta); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.meta); + await tester.pump(); + + expect(saves, 2); + }); + + testWidgets('a plain S does not save, so typing is safe', (tester) async { + var saves = 0; + await _pump( + tester, + SaveShortcut( + onSave: () => saves++, + child: const TextField(autofocus: true), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.pump(); + + expect(saves, 0); + }); + + testWidgets('a null onSave leaves the child alone', (tester) async { + await _pump(tester, const SaveShortcut(onSave: null, child: Text('form'))); + + expect(find.byType(CallbackShortcuts), findsNothing); + expect(find.text('form'), findsOneWidget); + }); + + testWidgets('left and right arrows step through a row', (tester) async { + final List steps = []; + await _pump( + tester, + HorizontalStepShortcuts( + onPrevious: () => steps.add('previous'), + onNext: () => steps.add('next'), + child: TextButton( + autofocus: true, + onPressed: () {}, + child: const Text('Lab'), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(steps, ['next', 'previous']); + }); + + testWidgets('Escape closes a modal bottom sheet', (tester) async { + await _pump( + tester, + Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => const SizedBox(height: 120, child: Text('editor')), + ), + child: const Text('open'), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('editor'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('editor'), findsNothing); + }); +} diff --git a/test/large_text_layout_test.dart b/test/large_text_layout_test.dart new file mode 100644 index 0000000..498eef6 --- /dev/null +++ b/test/large_text_layout_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:spectrumpit/src/models/borrow_record.dart'; +import 'package:spectrumpit/src/models/inventory_item.dart'; +import 'package:spectrumpit/src/models/packing_record.dart'; +import 'package:spectrumpit/src/services/spectrum_auth_service.dart'; +import 'package:spectrumpit/src/state/borrow_controller.dart'; +import 'package:spectrumpit/src/state/inventory_controller.dart'; +import 'package:spectrumpit/src/state/packing_controller.dart'; +import 'package:spectrumpit/src/theme/app_theme.dart'; +import 'package:spectrumpit/src/ui/borrow_tab.dart'; +import 'package:spectrumpit/src/ui/inventory_tab.dart'; +import 'package:spectrumpit/src/ui/packing_tab.dart'; + +import 'support/fake_borrow_sync_service.dart'; +import 'support/fake_inventory_sync_service.dart'; +import 'support/fake_packing_sync_service.dart'; +import 'support/fake_spectrum_auth_service.dart'; +import 'support/photo_test_support.dart'; + +/// #193: Windows and Apple both let a user run text at 200%, and this app +/// honours the setting, so the open question is whether a dense row survives it. +/// +/// An overflow reports a FlutterError while painting, which fails the test that +/// pumped it. So each case is "mount this tab at 200% on a narrow phone and let +/// it paint", and a regression names the widget that broke. +const SpectrumUser _user = SpectrumUser(uid: 'uid-1', displayName: 'Tester'); +const Size _smallPhone = Size(360, 760); +const TextScaler _doubled = TextScaler.linear(2.0); + +Future _pumpAtDoubleText(WidgetTester tester, Widget body) async { + tester.view.physicalSize = _smallPhone; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + theme: buildDarkAppTheme(), + // The app never sets textScaler itself, so overriding it here is exactly + // what a user with 200% text in their OS settings hands the app. + builder: (BuildContext context, Widget? child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _doubled), + child: child!, + ), + home: Scaffold(body: body), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('the inventory tab holds up at 200% text', (tester) async { + final sync = FakeInventorySyncService(); + final controller = InventoryController( + authService: FakeSpectrumAuthService(initialUser: _user), + syncService: sync, + ); + addTearDown(controller.dispose); + await controller.bootstrap(); + sync.emit([ + InventoryItem( + id: 'a', + name: 'Cordless drill with the long chuck', + labLocation: 'RC1-DB', + pitLocation: 'CAB-A2', + status: InventoryStatus.inLab, + updatedAt: DateTime.utc(2026, 1, 1), + ), + ]); + + await _pumpAtDoubleText(tester, InventoryTab(controller: controller)); + + // The seeded row has to be on screen, or the test would pass without ever + // painting the dense content it is meant to check. + expect(find.text('Cordless drill with the long chuck'), findsOneWidget); + expect(find.text('RC1-DB'), findsOneWidget); + expect(find.text('CAB-A2'), findsOneWidget); + }); + + testWidgets('the borrow tab holds up at 200% text', (tester) async { + final sync = FakeBorrowSyncService(); + final controller = BorrowController( + authService: FakeSpectrumAuthService(initialUser: _user), + syncService: sync, + ); + addTearDown(controller.dispose); + await controller.bootstrap(); + sync.emit([ + BorrowRecord( + id: 'a', + toolName: 'Rivet gun', + teamName: 'The Cheesy Poofs', + teamNumber: 254, + competition: 'Texas States', + checkedOutAt: DateTime.utc(2026, 3, 1, 10), + estimatedReturn: DateTime.utc(2026, 3, 1, 14), + returned: false, + updatedAt: DateTime.utc(2026, 3, 1, 10), + ), + ]); + + await _pumpAtDoubleText(tester, BorrowTab(controller: controller)); + + expect(find.text('Rivet gun'), findsOneWidget); + // Team and competition are rich paragraphs, so their text lives in spans. + expect( + find.textContaining('The Cheesy Poofs', findRichText: true), + findsOneWidget, + ); + expect( + find.textContaining('Texas States', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('the packing tab holds up at 200% text', (tester) async { + final sync = FakePackingSyncService(); + final controller = PackingController( + authService: FakeSpectrumAuthService(initialUser: _user), + syncService: sync, + ); + addTearDown(controller.dispose); + await controller.bootstrap(); + sync.emit([ + PackingRecord( + id: 'a', + itemId: 'Battery cart and its charger shelf', + packingStatus: PackingStatus.packing, + updatedAt: DateTime.utc(2026, 1, 1), + ), + ]); + + await _pumpAtDoubleText( + tester, + PackingTab( + controller: controller, + photoService: unavailablePhotoService(), + ), + ); + + expect(find.text('Battery cart and its charger shelf'), findsOneWidget); + }); +} diff --git a/test/maps_tab_test.dart b/test/maps_tab_test.dart index e406924..babf863 100644 --- a/test/maps_tab_test.dart +++ b/test/maps_tab_test.dart @@ -167,6 +167,50 @@ void main() { expect(find.byIcon(Icons.location_on_outlined), findsNWidgets(2)); }); + // A pin is a painted glyph in a bare gesture area, so assistive tech saw a + // 48px box with no name, and the diagram itself read as nothing at all (#191). + testWidgets('the diagram and its pins announce themselves', (tester) async { + final handle = tester.ensureSemantics(); + imageStore.images[MapType.lab] = _fakeDiagram(); + + await pumpTab( + tester, + pins: [ + _pin('a', name: 'Battery cart', x: 0.2, y: 0.3, inventoryItemId: 'i1'), + _pin('b', name: 'Charging station', x: 0.7, y: 0.6), + ], + ); + + expect( + find.bySemanticsLabel('Lab diagram, 2 locations marked'), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Battery cart, linked to a tool'), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Charging station, not linked to a tool'), + findsOneWidget, + ); + + handle.dispose(); + }); + + testWidgets('an empty diagram says nothing is marked', (tester) async { + final handle = tester.ensureSemantics(); + imageStore.images[MapType.lab] = _fakeDiagram(); + + await pumpTab(tester, pins: const []); + + expect( + find.bySemanticsLabel('Lab diagram, no locations marked'), + findsOneWidget, + ); + + handle.dispose(); + }); + testWidgets('tapping a pin shows its linked tool and locations', ( tester, ) async { diff --git a/test/support/fake_map_diagram_sync_service.dart b/test/support/fake_map_diagram_sync_service.dart index f15b7e1..d42612d 100644 --- a/test/support/fake_map_diagram_sync_service.dart +++ b/test/support/fake_map_diagram_sync_service.dart @@ -14,12 +14,20 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { Object? writeFailure, Object? clearFailure, this.onWriteKey, - }) : _readKeyValue = readKeyValue, - _readFailure = readFailure, + }) : _readFailure = readFailure, _writeFailure = writeFailure, - _clearFailure = clearFailure; + _clearFailure = clearFailure { + if (readKeyValue != null) { + for (final mapType in MapType.values) { + _readKeyValues[mapType] = readKeyValue; + } + } + } - String? _readKeyValue; + /// Keyed by map type, the way Firestore keys the real documents. A single + /// shared value let a lab write change what a pit read returned, so a + /// cross-map isolation defect would have passed (#184). + final Map _readKeyValues = {}; final Object? _readFailure; final Object? _writeFailure; final Object? _clearFailure; @@ -38,7 +46,7 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { @override Future readKey(MapType mapType) async { if (_readFailure != null) throw _readFailure; - return _readKeyValue; + return _readKeyValues[mapType]; } @override @@ -50,7 +58,7 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { // 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; + _readKeyValues[mapType] = key; } @override @@ -59,6 +67,6 @@ class FakeMapDiagramSyncService implements MapDiagramSyncService { 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; + _readKeyValues[mapType] = null; } } diff --git a/test/support/photo_test_support.dart b/test/support/photo_test_support.dart index fba3108..2bcd608 100644 --- a/test/support/photo_test_support.dart +++ b/test/support/photo_test_support.dart @@ -45,8 +45,12 @@ PhotoService fakePhotoService({ // 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) { + // A request with no Authorization header is rejected even when the fake + // has no token: the Worker gates on the header being present and valid, + // so accepting a missing one let an unauthenticated call reach the + // storage logic in a test and pass (#184). + if (token == null || + request.headers['Authorization'] != 'Bearer $token') { return http.Response('{"error":"Unauthorized"}', 401); } if (respond != null) return respond(request); diff --git a/test/synced_map_image_store_test.dart b/test/synced_map_image_store_test.dart index b9ff628..4512a87 100644 --- a/test/synced_map_image_store_test.dart +++ b/test/synced_map_image_store_test.dart @@ -188,11 +188,16 @@ void main() { expect(await store.diagramFor(MapType.lab), isNull); }); - test('remote clear failure still cleans up the local cache', () async { + test('remote clear failure cleans up locally, then reports', () 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. + // being removed. The remote pointer survives, so the team still sees the + // diagram. + // + // It does throw at the end, though (#184). Completing quietly dropped the + // caller to the empty state while the diagram was still shared, so it + // reappeared on the next load. The maps tab already renders a throw as a + // "could not remove" notice. final sync = FakeMapDiagramSyncService( readKeyValue: 'key-0.jpg', clearFailure: Exception('remote clear failed'), @@ -203,10 +208,12 @@ void main() { ); await store.pickDiagram(MapType.lab); - // Completes instead of throwing: the failure is contained to the remote - // pointer step. - await store.clearDiagram(MapType.lab); + await expectLater( + store.clearDiagram(MapType.lab), + throwsA(isA()), + ); + // The local cleanup still ran, in full, before the failure surfaced. expect(await sync.readKey(MapType.lab), 'key-0.jpg'); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('$_r2KeyPref${MapType.lab.name}'), isNull); diff --git a/test/user_scoped_firestore_cache_test.dart b/test/user_scoped_firestore_cache_test.dart new file mode 100644 index 0000000..e9f02ad --- /dev/null +++ b/test/user_scoped_firestore_cache_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spectrumpit/src/services/user_scoped_firestore_cache.dart'; + +void main() { + late Directory root; + String? uid; + + setUp(() async { + root = await Directory.systemTemp.createTemp('firestore_cache_test'); + uid = null; + }); + + tearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + + UserScopedFirestoreCache cache() => + UserScopedFirestoreCache(root: root, currentUid: () => uid); + + test('a read comes back for the user who wrote it', () async { + uid = 'userA'; + final subject = cache(); + await subject.write('doc/teams/1234', '{"a":1}'); + + expect(await subject.read('doc/teams/1234'), '{"a":1}'); + }); + + test('a second account is not served the first account data', () async { + final subject = cache(); + uid = 'userA'; + await subject.write('doc/teams/1234', '{"a":1}'); + + uid = 'userB'; + expect(await subject.read('doc/teams/1234'), isNull); + + // And the first account still has its own copy. + uid = 'userA'; + expect(await subject.read('doc/teams/1234'), '{"a":1}'); + }); + + test('nothing is cached while nobody is signed in', () async { + final subject = cache(); + await subject.write('doc/teams/1234', '{"a":1}'); + + expect(await subject.read('doc/teams/1234'), isNull); + expect(root.listSync(), isEmpty); + }); + + test('a uid that could escape the root gets no cache', () async { + final subject = cache(); + uid = '../elsewhere'; + await subject.write('doc/teams/1234', '{"a":1}'); + + expect(await subject.read('doc/teams/1234'), isNull); + expect(root.listSync(), isEmpty); + }); + + test('clear drops only the signed-in account', () async { + final subject = cache(); + uid = 'userA'; + await subject.write('doc/teams/1234', '{"a":1}'); + uid = 'userB'; + await subject.write('doc/teams/1234', '{"b":2}'); + + uid = 'userA'; + await subject.clear(); + + expect(await subject.read('doc/teams/1234'), isNull); + uid = 'userB'; + expect(await subject.read('doc/teams/1234'), '{"b":2}'); + }); + + test('clearForUid drops a user who is no longer the current one', () async { + final subject = cache(); + uid = 'userA'; + await subject.write('doc/teams/1234', '{"a":1}'); + + uid = null; + await subject.clearForUid('userA'); + + uid = 'userA'; + expect(await subject.read('doc/teams/1234'), isNull); + }); + + test('remove drops one key and leaves the rest', () async { + uid = 'userA'; + final subject = cache(); + await subject.write('doc/teams/1234', '{"a":1}'); + await subject.write('doc/teams/5678', '{"b":2}'); + + await subject.remove('doc/teams/1234'); + + expect(await subject.read('doc/teams/1234'), isNull); + expect(await subject.read('doc/teams/5678'), '{"b":2}'); + }); +}