diff --git a/client/lib/base/base_rail.dart b/client/lib/base/base_rail.dart index 4ed76b6..b0efaa5 100644 --- a/client/lib/base/base_rail.dart +++ b/client/lib/base/base_rail.dart @@ -64,7 +64,7 @@ class BaseRail extends HookConsumerWidget { ), NavigationRailDestination( icon: Icon(Icons.analytics), - label: Text('Stats'), + label: Text('Statistics'), ), ], ), diff --git a/client/lib/generated/api/api.pb.dart b/client/lib/generated/api/api.pb.dart index 3a3d6e0..0171747 100644 --- a/client/lib/generated/api/api.pb.dart +++ b/client/lib/generated/api/api.pb.dart @@ -20,7 +20,7 @@ export 'location.pb.dart'; export 'schedule.pb.dart'; export 'session.pb.dart'; export 'settings.pb.dart'; -export 'stats.pb.dart'; +export 'statistics.pb.dart'; export 'team_member.pb.dart'; export 'user.pb.dart'; diff --git a/client/lib/generated/api/api.pbenum.dart b/client/lib/generated/api/api.pbenum.dart index 5ce16a4..5ca7331 100644 --- a/client/lib/generated/api/api.pbenum.dart +++ b/client/lib/generated/api/api.pbenum.dart @@ -14,6 +14,6 @@ export 'location.pbenum.dart'; export 'schedule.pbenum.dart'; export 'session.pbenum.dart'; export 'settings.pbenum.dart'; -export 'stats.pbenum.dart'; +export 'statistics.pbenum.dart'; export 'team_member.pbenum.dart'; export 'user.pbenum.dart'; diff --git a/client/lib/generated/api/stats.pb.dart b/client/lib/generated/api/statistics.pb.dart similarity index 99% rename from client/lib/generated/api/stats.pb.dart rename to client/lib/generated/api/statistics.pb.dart index 5556efa..7981f7a 100644 --- a/client/lib/generated/api/stats.pb.dart +++ b/client/lib/generated/api/statistics.pb.dart @@ -1,6 +1,6 @@ // This is a generated file - do not edit. // -// Generated from api/stats.proto. +// Generated from api/statistics.proto. // @dart = 3.3 diff --git a/client/lib/generated/api/stats.pbenum.dart b/client/lib/generated/api/statistics.pbenum.dart similarity index 90% rename from client/lib/generated/api/stats.pbenum.dart rename to client/lib/generated/api/statistics.pbenum.dart index 205e813..e85c832 100644 --- a/client/lib/generated/api/stats.pbenum.dart +++ b/client/lib/generated/api/statistics.pbenum.dart @@ -1,6 +1,6 @@ // This is a generated file - do not edit. // -// Generated from api/stats.proto. +// Generated from api/statistics.proto. // @dart = 3.3 diff --git a/client/lib/generated/api/stats.pbgrpc.dart b/client/lib/generated/api/statistics.pbgrpc.dart similarity index 78% rename from client/lib/generated/api/stats.pbgrpc.dart rename to client/lib/generated/api/statistics.pbgrpc.dart index 93f279d..8a729ba 100644 --- a/client/lib/generated/api/stats.pbgrpc.dart +++ b/client/lib/generated/api/statistics.pbgrpc.dart @@ -1,6 +1,6 @@ // This is a generated file - do not edit. // -// Generated from api/stats.proto. +// Generated from api/statistics.proto. // @dart = 3.3 @@ -16,12 +16,12 @@ import 'dart:core' as $core; import 'package:grpc/service_api.dart' as $grpc; import 'package:protobuf/protobuf.dart' as $pb; -import 'stats.pb.dart' as $0; +import 'statistics.pb.dart' as $0; -export 'stats.pb.dart'; +export 'statistics.pb.dart'; -@$pb.GrpcServiceName('tk.api.StatsService') -class StatsServiceClient extends $grpc.Client { +@$pb.GrpcServiceName('tk.api.StatisticsService') +class StatisticsServiceClient extends $grpc.Client { /// The hostname for this service. static const $core.String defaultHost = ''; @@ -30,7 +30,7 @@ class StatsServiceClient extends $grpc.Client { '', ]; - StatsServiceClient(super.channel, {super.options, super.interceptors}); + StatisticsServiceClient(super.channel, {super.options, super.interceptors}); $grpc.ResponseFuture<$0.GetLeaderboardResponse> getLeaderboard( $0.GetLeaderboardRequest request, { @@ -43,16 +43,16 @@ class StatsServiceClient extends $grpc.Client { static final _$getLeaderboard = $grpc.ClientMethod<$0.GetLeaderboardRequest, $0.GetLeaderboardResponse>( - '/tk.api.StatsService/GetLeaderboard', + '/tk.api.StatisticsService/GetLeaderboard', ($0.GetLeaderboardRequest value) => value.writeToBuffer(), $0.GetLeaderboardResponse.fromBuffer); } -@$pb.GrpcServiceName('tk.api.StatsService') -abstract class StatsServiceBase extends $grpc.Service { - $core.String get $name => 'tk.api.StatsService'; +@$pb.GrpcServiceName('tk.api.StatisticsService') +abstract class StatisticsServiceBase extends $grpc.Service { + $core.String get $name => 'tk.api.StatisticsService'; - StatsServiceBase() { + StatisticsServiceBase() { $addMethod($grpc.ServiceMethod<$0.GetLeaderboardRequest, $0.GetLeaderboardResponse>( 'GetLeaderboard', diff --git a/client/lib/generated/api/stats.pbjson.dart b/client/lib/generated/api/statistics.pbjson.dart similarity index 98% rename from client/lib/generated/api/stats.pbjson.dart rename to client/lib/generated/api/statistics.pbjson.dart index d307b3a..a456dd0 100644 --- a/client/lib/generated/api/stats.pbjson.dart +++ b/client/lib/generated/api/statistics.pbjson.dart @@ -1,6 +1,6 @@ // This is a generated file - do not edit. // -// Generated from api/stats.proto. +// Generated from api/statistics.proto. // @dart = 3.3 diff --git a/client/lib/helpers/kiosk_mode_native.dart b/client/lib/helpers/kiosk_mode_native.dart new file mode 100644 index 0000000..14fd455 --- /dev/null +++ b/client/lib/helpers/kiosk_mode_native.dart @@ -0,0 +1,17 @@ +import 'package:window_manager/window_manager.dart'; + +bool get isKioskModeSupported => true; + +Future enableKioskMode() async { + await windowManager.ensureInitialized(); + await windowManager.setFullScreen(true); + await windowManager.setAlwaysOnTop(true); + await windowManager.setPreventClose(true); +} + +Future disableKioskMode() async { + await windowManager.ensureInitialized(); + await windowManager.setPreventClose(false); + await windowManager.setAlwaysOnTop(false); + await windowManager.setFullScreen(false); +} diff --git a/client/lib/helpers/kiosk_mode_stub.dart b/client/lib/helpers/kiosk_mode_stub.dart new file mode 100644 index 0000000..798d783 --- /dev/null +++ b/client/lib/helpers/kiosk_mode_stub.dart @@ -0,0 +1,5 @@ +bool get isKioskModeSupported => false; + +Future enableKioskMode() async {} + +Future disableKioskMode() async {} diff --git a/client/lib/helpers/kiosk_mode_web.dart b/client/lib/helpers/kiosk_mode_web.dart new file mode 100644 index 0000000..798d783 --- /dev/null +++ b/client/lib/helpers/kiosk_mode_web.dart @@ -0,0 +1,5 @@ +bool get isKioskModeSupported => false; + +Future enableKioskMode() async {} + +Future disableKioskMode() async {} diff --git a/client/lib/models/stats_data.dart b/client/lib/models/statistics_data.dart similarity index 88% rename from client/lib/models/stats_data.dart rename to client/lib/models/statistics_data.dart index 8e299eb..30e7676 100644 --- a/client/lib/models/stats_data.dart +++ b/client/lib/models/statistics_data.dart @@ -1,17 +1,17 @@ import 'package:time_keeper/generated/db/db.pb.dart'; -/// Time range filter for the stats dashboard. -enum StatsRange { day, week, month, all } +/// Time range filter for the statistics dashboard. +enum StatisticsRange { day, week, month, all } -String statsRangeLabel(StatsRange range) { +String statisticsRangeLabel(StatisticsRange range) { switch (range) { - case StatsRange.day: + case StatisticsRange.day: return 'Today'; - case StatsRange.week: + case StatisticsRange.week: return 'This Week'; - case StatsRange.month: + case StatisticsRange.month: return 'This Month'; - case StatsRange.all: + case StatisticsRange.all: return 'All Time'; } } diff --git a/client/lib/providers/kiosk_mode_provider.dart b/client/lib/providers/kiosk_mode_provider.dart new file mode 100644 index 0000000..abdea35 --- /dev/null +++ b/client/lib/providers/kiosk_mode_provider.dart @@ -0,0 +1,39 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:time_keeper/helpers/local_storage.dart'; +import 'package:time_keeper/utils/logger.dart'; + +import 'package:time_keeper/helpers/kiosk_mode_stub.dart' + if (dart.library.io) 'package:time_keeper/helpers/kiosk_mode_native.dart' + if (dart.library.js_interop) 'package:time_keeper/helpers/kiosk_mode_web.dart'; + +part 'kiosk_mode_provider.g.dart'; + +@Riverpod(keepAlive: true) +class KioskMode extends _$KioskMode { + static const String _key = 'kiosk_mode'; + + Future toggle() async { + final newValue = !state; + if (newValue) { + await enableKioskMode(); + logger.i('Kiosk mode enabled'); + } else { + await disableKioskMode(); + logger.i('Kiosk mode disabled'); + } + localStorage.setBool(_key, newValue); + state = newValue; + } + + @override + bool build() { + final stored = localStorage.getBool(_key) ?? false; + + // Restore kiosk mode on app start if it was previously enabled + if (stored && isKioskModeSupported) { + enableKioskMode(); + } + + return stored; + } +} diff --git a/client/lib/providers/kiosk_mode_provider.g.dart b/client/lib/providers/kiosk_mode_provider.g.dart new file mode 100644 index 0000000..5af5a21 --- /dev/null +++ b/client/lib/providers/kiosk_mode_provider.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'kiosk_mode_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(KioskMode) +final kioskModeProvider = KioskModeProvider._(); + +final class KioskModeProvider extends $NotifierProvider { + KioskModeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'kioskModeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$kioskModeHash(); + + @$internal + @override + KioskMode create() => KioskMode(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$kioskModeHash() => r'b01a050d71e406ddcbcfd3af73fb5dab1c563938'; + +abstract class _$KioskMode extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/client/lib/providers/stats_provider.dart b/client/lib/providers/statistics_provider.dart similarity index 66% rename from client/lib/providers/stats_provider.dart rename to client/lib/providers/statistics_provider.dart index 9c22095..199d956 100644 --- a/client/lib/providers/stats_provider.dart +++ b/client/lib/providers/statistics_provider.dart @@ -1,22 +1,22 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:time_keeper/generated/api/stats.pbgrpc.dart'; +import 'package:time_keeper/generated/api/statistics.pbgrpc.dart'; import 'package:time_keeper/helpers/auth_interceptor.dart'; import 'package:time_keeper/providers/auth_provider.dart'; import 'package:time_keeper/providers/grpc_channel_provider.dart'; -part 'stats_provider.g.dart'; +part 'statistics_provider.g.dart'; @Riverpod(keepAlive: true) -StatsServiceClient statsService(Ref ref) { +StatisticsServiceClient statisticsService(Ref ref) { final channel = ref.watch(grpcChannelProvider); final token = ref.watch(tokenProvider); final options = authCallOptions(token); - return StatsServiceClient(channel, options: options); + return StatisticsServiceClient(channel, options: options); } @riverpod Future leaderboard(Ref ref) async { - final client = ref.watch(statsServiceProvider); + final client = ref.watch(statisticsServiceProvider); return client.getLeaderboard(GetLeaderboardRequest()); } diff --git a/client/lib/providers/stats_provider.g.dart b/client/lib/providers/statistics_provider.g.dart similarity index 66% rename from client/lib/providers/stats_provider.g.dart rename to client/lib/providers/statistics_provider.g.dart index 87bfa2b..60be463 100644 --- a/client/lib/providers/stats_provider.g.dart +++ b/client/lib/providers/statistics_provider.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'stats_provider.dart'; +part of 'statistics_provider.dart'; // ************************************************************************** // RiverpodGenerator @@ -9,52 +9,52 @@ part of 'stats_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(statsService) -final statsServiceProvider = StatsServiceProvider._(); +@ProviderFor(statisticsService) +final statisticsServiceProvider = StatisticsServiceProvider._(); -final class StatsServiceProvider +final class StatisticsServiceProvider extends $FunctionalProvider< - StatsServiceClient, - StatsServiceClient, - StatsServiceClient + StatisticsServiceClient, + StatisticsServiceClient, + StatisticsServiceClient > - with $Provider { - StatsServiceProvider._() + with $Provider { + StatisticsServiceProvider._() : super( from: null, argument: null, retry: null, - name: r'statsServiceProvider', + name: r'statisticsServiceProvider', isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$statsServiceHash(); + String debugGetCreateSourceHash() => _$statisticsServiceHash(); @$internal @override - $ProviderElement $createElement( + $ProviderElement $createElement( $ProviderPointer pointer, ) => $ProviderElement(pointer); @override - StatsServiceClient create(Ref ref) { - return statsService(ref); + StatisticsServiceClient create(Ref ref) { + return statisticsService(ref); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(StatsServiceClient value) { + Override overrideWithValue(StatisticsServiceClient value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$statsServiceHash() => r'c44fcaa78d2b24b95e3f055bfb4a7906cf94f7ec'; +String _$statisticsServiceHash() => r'31e272fb381e3a6504e74ae2ff25b75fccf47364'; @ProviderFor(leaderboard) final leaderboardProvider = LeaderboardProvider._(); @@ -95,4 +95,4 @@ final class LeaderboardProvider } } -String _$leaderboardHash() => r'98e7c6033bc275fb745a458f3b2fba617455c888'; +String _$leaderboardHash() => r'654fe8fdaa27837695c270386b5da11dffa7552d'; diff --git a/client/lib/router/app_routes.dart b/client/lib/router/app_routes.dart index 354f95b..3a1cbd0 100644 --- a/client/lib/router/app_routes.dart +++ b/client/lib/router/app_routes.dart @@ -32,7 +32,7 @@ enum AppRoute { team(path: '/team', name: 'team', railIndex: 2), sessions(path: '/sessions', name: 'sessions', railIndex: 3), locations(path: '/locations', name: 'locations', railIndex: 4), - stats(path: '/stats', name: 'stats', railIndex: 5); + statistics(path: '/statistics', name: 'statistics', railIndex: 5); const AppRoute({ required this.path, diff --git a/client/lib/router/router.dart b/client/lib/router/router.dart index 0788714..baed204 100644 --- a/client/lib/router/router.dart +++ b/client/lib/router/router.dart @@ -23,7 +23,8 @@ import 'package:time_keeper/views/locations/locations_view.dart' import 'package:time_keeper/views/users/users_view.dart' deferred as users; import 'package:time_keeper/views/leaderboard/leaderboard_view.dart' deferred as leaderboard; -import 'package:time_keeper/views/stats/stats_view.dart' deferred as stats; +import 'package:time_keeper/views/statistics/statistics_view.dart' + deferred as statistics; import 'package:time_keeper/views/calendar/calendar_view.dart' deferred as calendar; @@ -115,18 +116,6 @@ GoRouter router(Ref ref) { ); }, routes: [ - GoRoute( - name: AppRoute.leaderboard.name, - path: AppRoute.leaderboard.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.leaderboard.path, - libraryLoader: leaderboard.loadLibrary, - builder: (context) => leaderboard.LeaderboardView(), - ), - ), - ), GoRoute( name: AppRoute.calendar.name, path: AppRoute.calendar.path, @@ -152,87 +141,112 @@ GoRouter router(Ref ref) { ), ), - // Protected Admin routes + // Protected Routes GoRoute( path: _protectedRoute, redirect: (context, state) { - if (!isLoggedIn && roles.hasPermission(Role.ADMIN)) { + if (!isLoggedIn) { return AppRoute.login.path; } return null; }, routes: [ GoRoute( - name: AppRoute.setup.name, - path: AppRoute.setup.path, + name: AppRoute.leaderboard.name, + path: AppRoute.leaderboard.path, pageBuilder: (context, state) => _buildTransitionPage( key: state.pageKey, child: DeferredWidget( - libraryKey: AppRoute.setup.path, - libraryLoader: setup.loadLibrary, - builder: (context) => setup.SetupView(), + libraryKey: AppRoute.leaderboard.path, + libraryLoader: leaderboard.loadLibrary, + builder: (context) => leaderboard.LeaderboardView(), ), ), ), + + // Protected Admin routes GoRoute( - name: AppRoute.users.name, - path: AppRoute.users.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.users.path, - libraryLoader: users.loadLibrary, - builder: (context) => users.UsersView(), + path: '/admin', + redirect: (context, state) { + if (!isLoggedIn && roles.hasPermission(Role.ADMIN)) { + return AppRoute.login.path; + } + return null; + }, + routes: [ + GoRoute( + name: AppRoute.setup.name, + path: AppRoute.setup.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.setup.path, + libraryLoader: setup.loadLibrary, + builder: (context) => setup.SetupView(), + ), + ), ), - ), - ), - GoRoute( - name: AppRoute.team.name, - path: AppRoute.team.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.team.path, - libraryLoader: team.loadLibrary, - builder: (context) => team.TeamView(), + GoRoute( + name: AppRoute.users.name, + path: AppRoute.users.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.users.path, + libraryLoader: users.loadLibrary, + builder: (context) => users.UsersView(), + ), + ), ), - ), - ), - GoRoute( - name: AppRoute.sessions.name, - path: AppRoute.sessions.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.sessions.path, - libraryLoader: sessions.loadLibrary, - builder: (context) => sessions.SessionView(), + GoRoute( + name: AppRoute.team.name, + path: AppRoute.team.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.team.path, + libraryLoader: team.loadLibrary, + builder: (context) => team.TeamView(), + ), + ), ), - ), - ), - GoRoute( - name: AppRoute.locations.name, - path: AppRoute.locations.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.locations.path, - libraryLoader: locations.loadLibrary, - builder: (context) => locations.LocationsView(), + GoRoute( + name: AppRoute.sessions.name, + path: AppRoute.sessions.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.sessions.path, + libraryLoader: sessions.loadLibrary, + builder: (context) => sessions.SessionView(), + ), + ), ), - ), - ), - GoRoute( - name: AppRoute.stats.name, - path: AppRoute.stats.path, - pageBuilder: (context, state) => _buildTransitionPage( - key: state.pageKey, - child: DeferredWidget( - libraryKey: AppRoute.stats.path, - libraryLoader: stats.loadLibrary, - builder: (context) => stats.StatsView(), + GoRoute( + name: AppRoute.locations.name, + path: AppRoute.locations.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.locations.path, + libraryLoader: locations.loadLibrary, + builder: (context) => locations.LocationsView(), + ), + ), ), - ), + GoRoute( + name: AppRoute.statistics.name, + path: AppRoute.statistics.path, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.statistics.path, + libraryLoader: statistics.loadLibrary, + builder: (context) => statistics.StatisticsView(), + ), + ), + ), + ], ), ], ), diff --git a/client/lib/router/router.g.dart b/client/lib/router/router.g.dart index 05e3752..9fd037e 100644 --- a/client/lib/router/router.g.dart +++ b/client/lib/router/router.g.dart @@ -48,4 +48,4 @@ final class RouterProvider } } -String _$routerHash() => r'a9d4f60a573f9b061c1dc2c0c04e20a20b7ef5dc'; +String _$routerHash() => r'aaa07851b3c83aa3ec549a8cf378eaa5eaf79f1f'; diff --git a/client/lib/utils/rfid_scanner.dart b/client/lib/utils/rfid_scanner.dart new file mode 100644 index 0000000..6b4048d --- /dev/null +++ b/client/lib/utils/rfid_scanner.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +final _log = Logger(); + +class RfidScanBuffer { + final void Function(String scannedInput) onScan; + + String _buffer = ''; + Timer? _timeout; + + static const _timeoutDuration = Duration(milliseconds: 500); + + RfidScanBuffer({required this.onScan}); + + bool handleKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return false; + + final key = event.logicalKey; + + // Enter key = end of scan + if (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter) { + if (_buffer.isNotEmpty) { + final input = _buffer.trim(); + _clear(); + _log.d('RFID scan received: $input'); + onScan(input); + } + return false; + } + + // Accumulate character input + final char = event.character; + if (char != null && char.isNotEmpty) { + _buffer += char; + _resetTimeout(); + return false; + } + + return false; + } + + void _resetTimeout() { + _timeout?.cancel(); + _timeout = Timer(_timeoutDuration, () { + if (_buffer.isNotEmpty) { + _log.t('RFID buffer timeout, clearing: $_buffer'); + _buffer = ''; + } + }); + } + + void _clear() { + _buffer = ''; + _timeout?.cancel(); + _timeout = null; + } + + void dispose() { + _clear(); + } +} diff --git a/client/lib/views/calendar/calendar_table.dart b/client/lib/views/calendar/calendar_table.dart index beadb25..d7d1660 100644 --- a/client/lib/views/calendar/calendar_table.dart +++ b/client/lib/views/calendar/calendar_table.dart @@ -3,11 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; import 'package:time_keeper/models/session_status.dart'; import 'package:time_keeper/providers/location_provider.dart'; -import 'package:time_keeper/providers/team_member_provider.dart'; import 'package:time_keeper/utils/formatting.dart'; import 'package:time_keeper/utils/time.dart'; -import 'package:time_keeper/views/sessions/session_detail_dialog.dart'; -import 'package:time_keeper/widgets/member_count.dart'; import 'package:time_keeper/widgets/status_chip.dart'; import 'package:time_keeper/widgets/tables/base_table.dart'; @@ -19,7 +16,6 @@ class CalendarTable extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final locations = ref.watch(locationsProvider); - final teamMembers = ref.watch(teamMembersProvider); final theme = Theme.of(context); return BaseTable( @@ -43,25 +39,17 @@ class CalendarTable extends ConsumerWidget { BaseTableCell( child: Text('Location', style: TextStyle(color: Colors.white)), ), - BaseTableCell( - child: Text('Members', style: TextStyle(color: Colors.white)), - ), BaseTableCell( child: Text('Status', style: TextStyle(color: Colors.white)), ), - BaseTableCell( - child: Text('', style: TextStyle(color: Colors.white)), - ), ], rows: sessions.map((entry) { - final id = entry.key; final session = entry.value; final start = session.startTime.toDateTime(); final end = session.endTime.toDateTime(); final duration = end.difference(start); final locationName = locations[session.locationId]?.location ?? session.locationId; - final memberCount = session.memberSessions.length; final status = getSessionStatus(session); return BaseTableRow( @@ -73,32 +61,7 @@ class CalendarTable extends ConsumerWidget { ), BaseTableCell(child: Text(formatDuration(duration))), BaseTableCell(child: Text(locationName)), - BaseTableCell( - child: MemberCount( - total: memberCount, - status: status, - session: session, - ), - ), BaseTableCell(child: SessionStatusChip(status: status)), - BaseTableCell( - child: IconButton( - icon: Icon( - Icons.visibility, - color: theme.colorScheme.primary, - size: 20, - ), - tooltip: 'View details', - onPressed: () => showSessionDetailDialog( - context, - ref, - sessionId: id, - session: session, - locations: locations, - teamMembers: teamMembers, - ), - ), - ), ], ); }).toList(), diff --git a/client/lib/views/kiosk/kiosk_scan_handler.dart b/client/lib/views/kiosk/kiosk_scan_handler.dart new file mode 100644 index 0000000..08661be --- /dev/null +++ b/client/lib/views/kiosk/kiosk_scan_handler.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:time_keeper/generated/api/api.pbgrpc.dart'; +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; +import 'package:time_keeper/providers/location_provider.dart'; +import 'package:time_keeper/providers/session_provider.dart'; +import 'package:time_keeper/providers/team_member_provider.dart'; +import 'package:time_keeper/utils/formatting.dart'; +import 'package:time_keeper/utils/grpc_result.dart'; +import 'package:time_keeper/widgets/dialogs/toast_overlay.dart'; + +final _log = Logger(); + +/// Handles an RFID scan input by matching it against team members +/// and checking them in/out. +Future handleKioskScan({ + required String input, + required BuildContext context, + required WidgetRef ref, +}) async { + final trimmed = input.trim().toLowerCase(); + if (trimmed.isEmpty) return; + + final teamMembers = ref.read(teamMembersProvider); + final match = _findMember(trimmed, teamMembers); + + if (match == null) { + _log.w('No member matched scan: $input'); + if (context.mounted) { + ToastOverlay.error( + context, + title: 'Unrecognized', + message: 'Unrecognized value "$input", contact admin.', + ); + } + return; + } + + final memberId = match.key; + final member = match.value; + final alias = member.alias.isNotEmpty ? ' (${member.alias})' : ''; + final name = '${member.firstName} ${member.lastName}$alias'; + + _log.i('Scan matched member: $name'); + + final currentLocation = ref.read(currentLocationProvider) ?? ''; + final result = await callGrpcEndpoint( + () => ref + .read(sessionServiceProvider) + .checkInOut( + CheckInOutRequest( + teamMemberId: memberId, + location: Location(location: currentLocation), + ), + ), + ); + + if (!context.mounted) return; + + final now = DateTime.now(); + final timeStr = formatTime(now); + + switch (result) { + case GrpcSuccess(data: final response): + if (response.checkedIn) { + ToastOverlay.success( + context, + title: 'Checked In', + message: '$name\n$timeStr', + ); + } else { + ToastOverlay.warn( + context, + title: 'Checked Out', + message: '$name\n$timeStr', + ); + } + case GrpcFailure(userMessage: final msg): + ToastOverlay.error( + context, + title: 'Check In Failed', + message: 'Failed for $name: $msg', + ); + } +} + +/// Tries to match scan input against team member fields. +/// Checks in order: first+last name, alias, secondary alias. +MapEntry? _findMember( + String input, + Map teamMembers, +) { + for (final entry in teamMembers.entries) { + final member = entry.value; + final fullName = '${member.firstName} ${member.lastName}' + .trim() + .toLowerCase(); + + if (fullName == input) return entry; + } + + for (final entry in teamMembers.entries) { + final member = entry.value; + + if (member.alias.isNotEmpty && member.alias.trim().toLowerCase() == input) { + return entry; + } + + if (member.secondaryAlias.isNotEmpty && + member.secondaryAlias.trim().toLowerCase() == input) { + return entry; + } + } + + return null; +} diff --git a/client/lib/views/kiosk/kiosk_view.dart b/client/lib/views/kiosk/kiosk_view.dart index 1a5631c..cf0bd54 100644 --- a/client/lib/views/kiosk/kiosk_view.dart +++ b/client/lib/views/kiosk/kiosk_view.dart @@ -1,15 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:time_keeper/generated/common/common.pbenum.dart'; import 'package:time_keeper/providers/auth_provider.dart'; import 'package:time_keeper/utils/permissions.dart'; import 'package:time_keeper/providers/session_provider.dart'; +import 'package:time_keeper/utils/rfid_scanner.dart'; import 'package:time_keeper/utils/time.dart'; import 'package:time_keeper/views/kiosk/checked_in_list.dart'; import 'package:time_keeper/views/kiosk/kiosk_dialog.dart'; +import 'package:time_keeper/views/kiosk/kiosk_scan_handler.dart'; import 'package:time_keeper/views/kiosk/session_info_bar.dart'; -class HomeView extends ConsumerWidget { +final _log = Logger(); + +class HomeView extends HookConsumerWidget { const HomeView({super.key}); @override @@ -36,6 +43,37 @@ class HomeView extends ConsumerWidget { final roles = ref.watch(rolesProvider); final hasKiosk = roles.any((role) => role.hasPermission(Role.KIOSK)); + // RFID keyboard listener - only active when user has KIOSK permission + final scanBuffer = useRef(null); + final contextRef = useRef(null); + final refRef = useRef(null); + contextRef.value = context; + refRef.value = ref; + + useEffect(() { + if (!hasKiosk) return null; + + final buffer = RfidScanBuffer( + onScan: (input) { + _log.i('RFID scan input: $input'); + final ctx = contextRef.value; + final r = refRef.value; + if (ctx != null && ctx.mounted && r != null) { + handleKioskScan(input: input, context: ctx, ref: r); + } + }, + ); + scanBuffer.value = buffer; + + HardwareKeyboard.instance.addHandler(buffer.handleKeyEvent); + + return () { + HardwareKeyboard.instance.removeHandler(buffer.handleKeyEvent); + buffer.dispose(); + scanBuffer.value = null; + }; + }, [hasKiosk]); + return Column( children: [ if (hasKiosk) diff --git a/client/lib/views/leaderboard/leaderboard_view.dart b/client/lib/views/leaderboard/leaderboard_view.dart index 5e80233..92e7385 100644 --- a/client/lib/views/leaderboard/leaderboard_view.dart +++ b/client/lib/views/leaderboard/leaderboard_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:time_keeper/generated/api/stats.pb.dart'; +import 'package:time_keeper/generated/api/statistics.pb.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; -import 'package:time_keeper/providers/stats_provider.dart'; +import 'package:time_keeper/providers/statistics_provider.dart'; import 'package:time_keeper/utils/formatting.dart'; class LeaderboardView extends ConsumerWidget { diff --git a/client/lib/views/locations/location_dialog.dart b/client/lib/views/locations/location_dialog.dart index 56dac23..7d481a1 100644 --- a/client/lib/views/locations/location_dialog.dart +++ b/client/lib/views/locations/location_dialog.dart @@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:time_keeper/generated/api/location.pbgrpc.dart'; import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; import 'package:time_keeper/providers/location_provider.dart'; +import 'package:time_keeper/utils/grpc_result.dart'; import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; void showLocationDialog( BuildContext context, @@ -18,7 +20,6 @@ void showLocationDialog( PopupDialog.info( title: isEdit ? 'Edit Location' : 'Add Location', message: _LocationForm( - ref: ref, isEdit: isEdit, locationId: id, initialName: existingName, @@ -48,22 +49,21 @@ void showDeleteLocationDialog( ).show(context); } -class _LocationForm extends HookWidget { - final WidgetRef ref; +class _LocationForm extends HookConsumerWidget { final bool isEdit; final String? locationId; final String? initialName; const _LocationForm({ - required this.ref, required this.isEdit, this.locationId, this.initialName, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final nameController = useTextEditingController(text: initialName ?? ''); + final isLoading = useState(false); return SizedBox( width: 400, @@ -83,51 +83,66 @@ class _LocationForm extends HookWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: isLoading.value + ? null + : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( - onPressed: () { - final name = nameController.text.trim(); - if (name.isEmpty) return; + onPressed: isLoading.value + ? null + : () async { + final name = nameController.text.trim(); + if (name.isEmpty) return; - Navigator.of(context).pop(); + isLoading.value = true; + try { + final client = ref.read(locationServiceProvider); + final GrpcResult result; + if (isEdit) { + result = await callGrpcEndpoint( + () => client.updateLocation( + UpdateLocationRequest( + id: locationId, + location: name, + ), + ), + ); + } else { + result = await callGrpcEndpoint( + () => client.createLocation( + CreateLocationRequest(location: name), + ), + ); + } - ConfirmDialog.info( - title: isEdit ? 'Update Location' : 'Create Location', - message: isEdit - ? Text('Save changes to "$name"?') - : Text('Create location "$name"?'), - confirmText: isEdit ? 'Save' : 'Create', - onConfirmAsyncGrpc: () async { - final client = ref.read(locationServiceProvider); - if (isEdit) { - return await callGrpcEndpoint( - () => client.updateLocation( - UpdateLocationRequest( - id: locationId, - location: name, - ), - ), - ); - } else { - return await callGrpcEndpoint( - () => client.createLocation( - CreateLocationRequest(location: name), - ), - ); - } - }, - showResultDialog: true, - successMessage: Text( - isEdit - ? '"$name" updated successfully' - : '"$name" created successfully', - ), - ).show(context); - }, - child: Text(isEdit ? 'Save' : 'Create'), + if (context.mounted) { + Navigator.of(context).pop(); + switch (result) { + case GrpcSuccess(): + SnackBarDialog.success( + message: isEdit + ? '"$name" updated successfully' + : '"$name" created successfully', + ).show(context); + case GrpcFailure(): + SnackBarDialog.fromGrpcStatus( + result: result, + ).show(context); + } + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? 'Save' : 'Create'), ), ], ), diff --git a/client/lib/views/sessions/session_detail_dialog.dart b/client/lib/views/sessions/session_detail_dialog.dart index 93fcd5d..dc4e230 100644 --- a/client/lib/views/sessions/session_detail_dialog.dart +++ b/client/lib/views/sessions/session_detail_dialog.dart @@ -65,69 +65,69 @@ void showSessionDetailDialog( else ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), - child: ListView.builder( - shrinkWrap: true, - itemCount: memberSessions.length, - itemBuilder: (context, index) { - final ms = memberSessions[index]; - final member = teamMembers[ms.teamMemberId]; - final name = member != null - ? '${member.firstName} ${member.lastName}' - : ms.teamMemberId; + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: memberSessions.map((ms) { + final member = teamMembers[ms.teamMemberId]; + final name = member != null + ? '${member.firstName} ${member.lastName}' + : ms.teamMemberId; - final checkIn = ms.hasCheckInTime() - ? formatTime(ms.checkInTime.toDateTime()) - : '\u2014'; - final checkOut = ms.hasCheckOutTime() - ? formatTime(ms.checkOutTime.toDateTime()) - : '\u2014'; + final checkIn = ms.hasCheckInTime() + ? formatTime(ms.checkInTime.toDateTime()) + : '\u2014'; + final checkOut = ms.hasCheckOutTime() + ? formatTime(ms.checkOutTime.toDateTime()) + : '\u2014'; - Duration? memberDuration; - if (ms.hasCheckInTime() && ms.hasCheckOutTime()) { - memberDuration = ms.checkOutTime.toDateTime().difference( - ms.checkInTime.toDateTime(), - ); - } + Duration? memberDuration; + if (ms.hasCheckInTime() && ms.hasCheckOutTime()) { + memberDuration = ms.checkOutTime.toDateTime().difference( + ms.checkInTime.toDateTime(), + ); + } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Icon( - ms.hasCheckOutTime() - ? Icons.check_circle - : Icons.radio_button_checked, - size: 16, - color: ms.hasCheckOutTime() - ? Colors.grey - : Colors.green, - ), - const SizedBox(width: 8), - Expanded(child: Text(name)), - Text( - '$checkIn - $checkOut', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 13, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + ms.hasCheckOutTime() + ? Icons.check_circle + : Icons.radio_button_checked, + size: 16, + color: ms.hasCheckOutTime() + ? Colors.grey + : Colors.green, ), - ), - if (memberDuration != null) ...[ - const SizedBox(width: 12), + const SizedBox(width: 8), + Expanded(child: Text(name)), Text( - formatDuration(memberDuration), + '$checkIn - $checkOut', style: TextStyle( - fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontSize: 13, - color: Theme.of(context).colorScheme.primary, ), ), + if (memberDuration != null) ...[ + const SizedBox(width: 12), + Text( + formatDuration(memberDuration), + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], ], - ], - ), - ); - }, + ), + ); + }).toList(), + ), ), ), ], diff --git a/client/lib/views/sessions/session_dialog.dart b/client/lib/views/sessions/session_dialog.dart index 997097d..9a8b539 100644 --- a/client/lib/views/sessions/session_dialog.dart +++ b/client/lib/views/sessions/session_dialog.dart @@ -6,10 +6,12 @@ import 'package:time_keeper/generated/db/db.pb.dart'; import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; import 'package:time_keeper/providers/location_provider.dart'; import 'package:time_keeper/providers/session_provider.dart'; +import 'package:time_keeper/utils/grpc_result.dart'; import 'package:time_keeper/utils/time.dart'; import 'package:time_keeper/utils/formatting.dart'; import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; void showSessionDialog( BuildContext context, @@ -22,7 +24,6 @@ void showSessionDialog( PopupDialog.info( title: isEdit ? 'Edit Session' : 'Create Session', message: _SessionForm( - ref: ref, isEdit: isEdit, sessionId: id, existingSession: existingSession, @@ -55,21 +56,19 @@ void showDeleteSessionDialog( ).show(context); } -class _SessionForm extends HookWidget { - final WidgetRef ref; +class _SessionForm extends HookConsumerWidget { final bool isEdit; final String? sessionId; final Session? existingSession; const _SessionForm({ - required this.ref, required this.isEdit, this.sessionId, this.existingSession, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final locations = ref.watch(locationsProvider); final now = DateTime.now(); @@ -84,6 +83,7 @@ class _SessionForm extends HookWidget { ); final selectedLocationId = useState(existingSession?.locationId); final finished = useState(existingSession?.finished ?? false); + final isLoading = useState(false); final locationEntries = locations.entries.toList() ..sort((a, b) => a.value.location.compareTo(b.value.location)); @@ -182,86 +182,100 @@ class _SessionForm extends HookWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: isLoading.value + ? null + : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( - onPressed: () { - final locationId = selectedLocationId.value; - if (locationId == null) return; + onPressed: isLoading.value + ? null + : () async { + final locationId = selectedLocationId.value; + if (locationId == null) return; - final startDt = DateTime( - startDate.value.year, - startDate.value.month, - startDate.value.day, - startTime.value.hour, - startTime.value.minute, - ); - final endDt = DateTime( - endDate.value.year, - endDate.value.month, - endDate.value.day, - endTime.value.hour, - endTime.value.minute, - ); + final startDt = DateTime( + startDate.value.year, + startDate.value.month, + startDate.value.day, + startTime.value.hour, + startTime.value.minute, + ); + final endDt = DateTime( + endDate.value.year, + endDate.value.month, + endDate.value.day, + endTime.value.hour, + endTime.value.minute, + ); - if (endDt.isBefore(startDt) || - endDt.isAtSameMomentAs(startDt)) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('End time must be after start time'), - ), - ); - return; - } + if (endDt.isBefore(startDt) || + endDt.isAtSameMomentAs(startDt)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'End time must be after start time', + ), + ), + ); + return; + } - final label = '${formatDate(startDt)} ${formatTime(startDt)}'; - Navigator.of(context).pop(); + isLoading.value = true; + try { + final client = ref.read(sessionServiceProvider); + final GrpcResult result; + if (isEdit) { + result = await callGrpcEndpoint( + () => client.updateSession( + UpdateSessionRequest( + id: sessionId, + startTime: startDt.toTimestamp(), + endTime: endDt.toTimestamp(), + locationId: locationId, + finished: finished.value, + ), + ), + ); + } else { + result = await callGrpcEndpoint( + () => client.createSession( + CreateSessionRequest( + startTime: startDt.toTimestamp(), + endTime: endDt.toTimestamp(), + locationId: locationId, + ), + ), + ); + } - ConfirmDialog.info( - title: isEdit ? 'Update Session' : 'Create Session', - message: Text( - isEdit - ? 'Save changes to session on $label?' - : 'Create session on $label?', - ), - confirmText: isEdit ? 'Save' : 'Create', - onConfirmAsyncGrpc: () async { - final client = ref.read(sessionServiceProvider); - if (isEdit) { - return await callGrpcEndpoint( - () => client.updateSession( - UpdateSessionRequest( - id: sessionId, - startTime: startDt.toTimestamp(), - endTime: endDt.toTimestamp(), - locationId: locationId, - finished: finished.value, - ), - ), - ); - } else { - return await callGrpcEndpoint( - () => client.createSession( - CreateSessionRequest( - startTime: startDt.toTimestamp(), - endTime: endDt.toTimestamp(), - locationId: locationId, - ), - ), - ); - } - }, - showResultDialog: true, - successMessage: Text( - isEdit - ? 'Session updated successfully' - : 'Session created successfully', - ), - ).show(context); - }, - child: Text(isEdit ? 'Save' : 'Create'), + if (context.mounted) { + Navigator.of(context).pop(); + switch (result) { + case GrpcSuccess(): + SnackBarDialog.success( + message: isEdit + ? 'Session updated successfully' + : 'Session created successfully', + ).show(context); + case GrpcFailure(): + SnackBarDialog.fromGrpcStatus( + result: result, + ).show(context); + } + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? 'Save' : 'Create'), ), ], ), diff --git a/client/lib/views/settings/settings_view.dart b/client/lib/views/settings/settings_view.dart index 9052ff2..c4dcde4 100644 --- a/client/lib/views/settings/settings_view.dart +++ b/client/lib/views/settings/settings_view.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/helpers/kiosk_mode_stub.dart' + if (dart.library.io) 'package:time_keeper/helpers/kiosk_mode_native.dart' + if (dart.library.js_interop) 'package:time_keeper/helpers/kiosk_mode_web.dart'; +import 'package:time_keeper/providers/kiosk_mode_provider.dart'; import 'package:time_keeper/providers/location_provider.dart'; import 'package:time_keeper/providers/network_config_provider.dart'; import 'package:time_keeper/views/setup/common/dropdown_setting.dart'; @@ -33,6 +37,7 @@ class SettingsView extends HookConsumerWidget { ); final selectedLocationId = useState(currentLocationId); + final kioskMode = ref.watch(kioskModeProvider); return SettingsPageLayout( title: 'Settings', @@ -62,6 +67,19 @@ class SettingsView extends HookConsumerWidget { }, ), const SizedBox(height: 24), + SwitchListTile( + title: const Text('Kiosk Mode'), + subtitle: Text( + isKioskModeSupported + ? 'Lock the app fullscreen and always-on-top (desktop only)' + : 'Only available on desktop platforms', + ), + value: kioskMode, + onChanged: isKioskModeSupported + ? (_) => ref.read(kioskModeProvider.notifier).toggle() + : null, + ), + const SizedBox(height: 24), const Divider(), const SizedBox(height: 24), TextFieldSetting( diff --git a/client/lib/views/stats/stats_attendance_chart.dart b/client/lib/views/statistics/statistics_attendance_chart.dart similarity index 98% rename from client/lib/views/stats/stats_attendance_chart.dart rename to client/lib/views/statistics/statistics_attendance_chart.dart index 04fdcd2..4fae5b9 100644 --- a/client/lib/views/stats/stats_attendance_chart.dart +++ b/client/lib/views/statistics/statistics_attendance_chart.dart @@ -1,14 +1,14 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:time_keeper/utils/formatting.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; -class StatsAttendanceChart extends StatelessWidget { +class StatisticsAttendanceChart extends StatelessWidget { final List dailyAttendance; final DateTime? selectedDay; final ValueChanged onDaySelected; - const StatsAttendanceChart({ + const StatisticsAttendanceChart({ super.key, required this.dailyAttendance, required this.selectedDay, diff --git a/client/lib/views/stats/stats_day_detail.dart b/client/lib/views/statistics/statistics_day_detail.dart similarity index 97% rename from client/lib/views/stats/stats_day_detail.dart rename to client/lib/views/statistics/statistics_day_detail.dart index e43df80..d956064 100644 --- a/client/lib/views/stats/stats_day_detail.dart +++ b/client/lib/views/statistics/statistics_day_detail.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; import 'package:time_keeper/utils/formatting.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; import 'package:time_keeper/widgets/tables/header_text.dart'; -class StatsDayDetail extends StatelessWidget { +class StatisticsDayDetail extends StatelessWidget { final DateTime selectedDay; final List members; final VoidCallback onClose; - const StatsDayDetail({ + const StatisticsDayDetail({ super.key, required this.selectedDay, required this.members, diff --git a/client/lib/views/stats/stats_helpers.dart b/client/lib/views/statistics/statistics_helpers.dart similarity index 96% rename from client/lib/views/stats/stats_helpers.dart rename to client/lib/views/statistics/statistics_helpers.dart index b536294..f72a65f 100644 --- a/client/lib/views/stats/stats_helpers.dart +++ b/client/lib/views/statistics/statistics_helpers.dart @@ -1,9 +1,9 @@ import 'package:time_keeper/generated/db/db.pb.dart'; -import 'package:time_keeper/models/stats_data.dart'; +import 'package:time_keeper/models/statistics_data.dart'; import 'package:time_keeper/utils/formatting.dart'; import 'package:time_keeper/utils/time.dart'; -export 'package:time_keeper/models/stats_data.dart'; +export 'package:time_keeper/models/statistics_data.dart'; export 'package:time_keeper/utils/formatting.dart' show formatSecsAsHoursMinutes; @@ -303,26 +303,26 @@ AttendanceInsights computeInsights( /// Filter sessions by time range based on session start time. Map filterSessionsByRange( Map sessions, - StatsRange range, + StatisticsRange range, ) { - if (range == StatsRange.all) return sessions; + if (range == StatisticsRange.all) return sessions; final now = DateTime.now(); late final DateTime start; late final DateTime end; switch (range) { - case StatsRange.day: + case StatisticsRange.day: start = DateTime(now.year, now.month, now.day); end = DateTime(now.year, now.month, now.day + 1); - case StatsRange.week: + case StatisticsRange.week: final monday = now.subtract(Duration(days: now.weekday - 1)); start = DateTime(monday.year, monday.month, monday.day); end = DateTime(monday.year, monday.month, monday.day + 7); - case StatsRange.month: + case StatisticsRange.month: start = DateTime(now.year, now.month, 1); end = DateTime(now.year, now.month + 1, 1); - case StatsRange.all: + case StatisticsRange.all: return sessions; } diff --git a/client/lib/views/stats/stats_hours_chart.dart b/client/lib/views/statistics/statistics_hours_chart.dart similarity index 98% rename from client/lib/views/stats/stats_hours_chart.dart rename to client/lib/views/statistics/statistics_hours_chart.dart index 2c66556..5b61f77 100644 --- a/client/lib/views/stats/stats_hours_chart.dart +++ b/client/lib/views/statistics/statistics_hours_chart.dart @@ -1,14 +1,14 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:time_keeper/utils/formatting.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; -class StatsHoursChart extends StatelessWidget { +class StatisticsHoursChart extends StatelessWidget { final List dailyHours; final DateTime? selectedDay; final ValueChanged onDaySelected; - const StatsHoursChart({ + const StatisticsHoursChart({ super.key, required this.dailyHours, required this.selectedDay, diff --git a/client/lib/views/stats/stats_location_chart.dart b/client/lib/views/statistics/statistics_location_chart.dart similarity index 96% rename from client/lib/views/stats/stats_location_chart.dart rename to client/lib/views/statistics/statistics_location_chart.dart index c853823..21ee0d2 100644 --- a/client/lib/views/stats/stats_location_chart.dart +++ b/client/lib/views/statistics/statistics_location_chart.dart @@ -1,12 +1,12 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:time_keeper/colors.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; -class StatsLocationChart extends StatelessWidget { +class StatisticsLocationChart extends StatelessWidget { final List locationData; - const StatsLocationChart({super.key, required this.locationData}); + const StatisticsLocationChart({super.key, required this.locationData}); @override Widget build(BuildContext context) { diff --git a/client/lib/views/stats/stats_member_hours_table.dart b/client/lib/views/statistics/statistics_member_hours_table.dart similarity index 96% rename from client/lib/views/stats/stats_member_hours_table.dart rename to client/lib/views/statistics/statistics_member_hours_table.dart index 66ef9aa..990c6b0 100644 --- a/client/lib/views/stats/stats_member_hours_table.dart +++ b/client/lib/views/statistics/statistics_member_hours_table.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; import 'package:time_keeper/widgets/tables/header_text.dart'; -class StatsMemberHoursTable extends StatelessWidget { +class StatisticsMemberHoursTable extends StatelessWidget { final Map memberHours; - const StatsMemberHoursTable({super.key, required this.memberHours}); + const StatisticsMemberHoursTable({super.key, required this.memberHours}); @override Widget build(BuildContext context) { diff --git a/client/lib/views/stats/stats_overtime_table.dart b/client/lib/views/statistics/statistics_overtime_table.dart similarity index 97% rename from client/lib/views/stats/stats_overtime_table.dart rename to client/lib/views/statistics/statistics_overtime_table.dart index b6d3ece..92559cf 100644 --- a/client/lib/views/stats/stats_overtime_table.dart +++ b/client/lib/views/statistics/statistics_overtime_table.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; import 'package:time_keeper/widgets/tables/header_text.dart'; -class StatsOvertimeTable extends StatelessWidget { +class StatisticsOvertimeTable extends StatelessWidget { final Map memberHours; - const StatsOvertimeTable({super.key, required this.memberHours}); + const StatisticsOvertimeTable({super.key, required this.memberHours}); @override Widget build(BuildContext context) { diff --git a/client/lib/views/stats/stats_overview_cards.dart b/client/lib/views/statistics/statistics_overview_cards.dart similarity index 94% rename from client/lib/views/stats/stats_overview_cards.dart rename to client/lib/views/statistics/statistics_overview_cards.dart index 657c36a..068f0ae 100644 --- a/client/lib/views/stats/stats_overview_cards.dart +++ b/client/lib/views/statistics/statistics_overview_cards.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; import 'package:time_keeper/utils/time.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; import 'package:time_keeper/widgets/stat_card.dart'; -class StatsOverviewCards extends StatelessWidget { +class StatisticsOverviewCards extends StatelessWidget { final Map filteredSessions; final Map memberHours; final AttendanceInsights insights; - const StatsOverviewCards({ + const StatisticsOverviewCards({ super.key, required this.filteredSessions, required this.memberHours, diff --git a/client/lib/views/stats/stats_view.dart b/client/lib/views/statistics/statistics_view.dart similarity index 75% rename from client/lib/views/stats/stats_view.dart rename to client/lib/views/statistics/statistics_view.dart index 7438d75..d6cf7c6 100644 --- a/client/lib/views/stats/stats_view.dart +++ b/client/lib/views/statistics/statistics_view.dart @@ -4,17 +4,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:time_keeper/providers/location_provider.dart'; import 'package:time_keeper/providers/session_provider.dart'; import 'package:time_keeper/providers/team_member_provider.dart'; -import 'package:time_keeper/views/stats/stats_attendance_chart.dart'; -import 'package:time_keeper/views/stats/stats_day_detail.dart'; -import 'package:time_keeper/views/stats/stats_helpers.dart'; -import 'package:time_keeper/views/stats/stats_hours_chart.dart'; -import 'package:time_keeper/views/stats/stats_location_chart.dart'; -import 'package:time_keeper/views/stats/stats_member_hours_table.dart'; -import 'package:time_keeper/views/stats/stats_overview_cards.dart'; -import 'package:time_keeper/views/stats/stats_overtime_table.dart'; +import 'package:time_keeper/views/statistics/statistics_attendance_chart.dart'; +import 'package:time_keeper/views/statistics/statistics_day_detail.dart'; +import 'package:time_keeper/views/statistics/statistics_helpers.dart'; +import 'package:time_keeper/views/statistics/statistics_hours_chart.dart'; +import 'package:time_keeper/views/statistics/statistics_location_chart.dart'; +import 'package:time_keeper/views/statistics/statistics_member_hours_table.dart'; +import 'package:time_keeper/views/statistics/statistics_overview_cards.dart'; +import 'package:time_keeper/views/statistics/statistics_overtime_table.dart'; -class StatsView extends HookConsumerWidget { - const StatsView({super.key}); +class StatisticsView extends HookConsumerWidget { + const StatisticsView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -23,7 +23,7 @@ class StatsView extends HookConsumerWidget { final locations = ref.watch(locationsProvider); final theme = Theme.of(context); - final selectedRange = useState(StatsRange.week); + final selectedRange = useState(StatisticsRange.week); final selectedDay = useState(null); final filtered = filterSessionsByRange(sessions, selectedRange.value); @@ -51,14 +51,17 @@ class StatsView extends HookConsumerWidget { children: [ Icon(Icons.analytics, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text('Stats Dashboard', style: theme.textTheme.headlineMedium), + Text( + 'Statistics Dashboard', + style: theme.textTheme.headlineMedium, + ), const Spacer(), - SegmentedButton( - segments: StatsRange.values + SegmentedButton( + segments: StatisticsRange.values .map( (r) => ButtonSegment( value: r, - label: Text(statsRangeLabel(r)), + label: Text(statisticsRangeLabel(r)), ), ) .toList(), @@ -79,7 +82,7 @@ class StatsView extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Overview cards - StatsOverviewCards( + StatisticsOverviewCards( filteredSessions: filtered, memberHours: memberHours, insights: insights, @@ -92,7 +95,7 @@ class StatsView extends HookConsumerWidget { children: [ Expanded( flex: 3, - child: StatsHoursChart( + child: StatisticsHoursChart( dailyHours: dailyHours, selectedDay: selectedDay.value, onDaySelected: onDaySelected, @@ -101,7 +104,7 @@ class StatsView extends HookConsumerWidget { const SizedBox(width: 16), Expanded( flex: 2, - child: StatsLocationChart( + child: StatisticsLocationChart( locationData: locationAttendance, ), ), @@ -110,7 +113,7 @@ class StatsView extends HookConsumerWidget { const SizedBox(height: 16), // People per day chart - StatsAttendanceChart( + StatisticsAttendanceChart( dailyAttendance: dailyAttendance, selectedDay: selectedDay.value, onDaySelected: onDaySelected, @@ -121,7 +124,7 @@ class StatsView extends HookConsumerWidget { if (selectedDay.value != null) Padding( padding: const EdgeInsets.only(bottom: 16), - child: StatsDayDetail( + child: StatisticsDayDetail( selectedDay: selectedDay.value!, members: dayMemberDetails, onClose: () => selectedDay.value = null, @@ -132,12 +135,12 @@ class StatsView extends HookConsumerWidget { AttendanceInsightsCards(insights: insights), const SizedBox(height: 24), - // Member hours table - StatsMemberHoursTable(memberHours: memberHours), + // Overtime flags table + StatisticsOvertimeTable(memberHours: memberHours), const SizedBox(height: 24), - // Overtime flags table - StatsOvertimeTable(memberHours: memberHours), + // Member hours table + StatisticsMemberHoursTable(memberHours: memberHours), ], ), ), diff --git a/client/lib/views/team/team_member_dialog.dart b/client/lib/views/team/team_member_dialog.dart index 0748666..f538d3b 100644 --- a/client/lib/views/team/team_member_dialog.dart +++ b/client/lib/views/team/team_member_dialog.dart @@ -5,8 +5,10 @@ import 'package:time_keeper/generated/api/team_member.pbgrpc.dart'; import 'package:time_keeper/generated/db/db.pbenum.dart'; import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; import 'package:time_keeper/providers/team_member_provider.dart'; +import 'package:time_keeper/utils/grpc_result.dart'; import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; void showTeamMemberDialog( BuildContext context, @@ -23,7 +25,6 @@ void showTeamMemberDialog( PopupDialog.info( title: isEdit ? 'Edit Team Member' : 'Add Team Member', message: _TeamMemberForm( - ref: ref, isEdit: isEdit, memberId: id, initialFirstName: existingFirstName, @@ -57,8 +58,7 @@ void showDeleteTeamMemberDialog( ).show(context); } -class _TeamMemberForm extends HookWidget { - final WidgetRef ref; +class _TeamMemberForm extends HookConsumerWidget { final bool isEdit; final String? memberId; final String? initialFirstName; @@ -68,7 +68,6 @@ class _TeamMemberForm extends HookWidget { final String? initialSecondaryAlias; const _TeamMemberForm({ - required this.ref, required this.isEdit, this.memberId, this.initialFirstName, @@ -79,7 +78,7 @@ class _TeamMemberForm extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final firstNameController = useTextEditingController( text: initialFirstName ?? '', ); @@ -93,6 +92,7 @@ class _TeamMemberForm extends HookWidget { final memberType = useState( initialMemberType ?? TeamMemberType.STUDENT, ); + final isLoading = useState(false); return SizedBox( width: 400, @@ -163,72 +163,85 @@ class _TeamMemberForm extends HookWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: isLoading.value + ? null + : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( - onPressed: () { - final firstName = firstNameController.text.trim(); - final lastName = lastNameController.text.trim(); + onPressed: isLoading.value + ? null + : () async { + final firstName = firstNameController.text.trim(); + final lastName = lastNameController.text.trim(); + final alias = aliasController.text.trim(); + final secondaryAlias = secondaryAliasController.text + .trim(); + final type = memberType.value; + final displayName = '$firstName $lastName'; - if (firstName.isEmpty || lastName.isEmpty) return; + isLoading.value = true; + try { + final client = ref.read(teamMemberServiceProvider); + final GrpcResult result; + if (isEdit) { + result = await callGrpcEndpoint( + () => client.updateTeamMember( + UpdateTeamMemberRequest( + id: memberId, + firstName: firstName, + lastName: lastName, + memberType: type, + alias: alias.isNotEmpty ? alias : null, + secondaryAlias: secondaryAlias.isNotEmpty + ? secondaryAlias + : null, + ), + ), + ); + } else { + result = await callGrpcEndpoint( + () => client.createTeamMember( + CreateTeamMemberRequest( + firstName: firstName, + lastName: lastName, + memberType: type, + alias: alias.isNotEmpty ? alias : null, + secondaryAlias: secondaryAlias.isNotEmpty + ? secondaryAlias + : null, + ), + ), + ); + } - final alias = aliasController.text.trim(); - final secondaryAlias = secondaryAliasController.text.trim(); - final type = memberType.value; - final displayName = '$firstName $lastName'; - - Navigator.of(context).pop(); - - ConfirmDialog.info( - title: isEdit ? 'Update Team Member' : 'Create Team Member', - message: isEdit - ? Text('Save changes to "$displayName"?') - : Text('Create team member "$displayName"?'), - confirmText: isEdit ? 'Save' : 'Create', - onConfirmAsyncGrpc: () async { - final client = ref.read(teamMemberServiceProvider); - if (isEdit) { - return await callGrpcEndpoint( - () => client.updateTeamMember( - UpdateTeamMemberRequest( - id: memberId, - firstName: firstName, - lastName: lastName, - memberType: type, - alias: alias.isNotEmpty ? alias : null, - secondaryAlias: secondaryAlias.isNotEmpty - ? secondaryAlias - : null, - ), - ), - ); - } else { - return await callGrpcEndpoint( - () => client.createTeamMember( - CreateTeamMemberRequest( - firstName: firstName, - lastName: lastName, - memberType: type, - alias: alias.isNotEmpty ? alias : null, - secondaryAlias: secondaryAlias.isNotEmpty - ? secondaryAlias - : null, - ), - ), - ); - } - }, - showResultDialog: true, - successMessage: Text( - isEdit - ? '"$displayName" updated successfully' - : '"$displayName" created successfully', - ), - ).show(context); - }, - child: Text(isEdit ? 'Save' : 'Create'), + if (context.mounted) { + Navigator.of(context).pop(); + switch (result) { + case GrpcSuccess(): + SnackBarDialog.success( + message: isEdit + ? '"$displayName" updated successfully' + : '"$displayName" created successfully', + ).show(context); + case GrpcFailure(): + SnackBarDialog.fromGrpcStatus( + result: result, + ).show(context); + } + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? 'Save' : 'Create'), ), ], ), diff --git a/client/lib/views/users/user_dialog.dart b/client/lib/views/users/user_dialog.dart index 80bc7be..3cea65b 100644 --- a/client/lib/views/users/user_dialog.dart +++ b/client/lib/views/users/user_dialog.dart @@ -5,8 +5,10 @@ import 'package:time_keeper/generated/api/user.pbgrpc.dart'; import 'package:time_keeper/generated/common/common.pbenum.dart'; import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; import 'package:time_keeper/providers/auth_provider.dart'; +import 'package:time_keeper/utils/grpc_result.dart'; import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; void showUserDialog( BuildContext context, @@ -20,7 +22,6 @@ void showUserDialog( PopupDialog.info( title: isEdit ? 'Edit User' : 'Add User', message: _UserForm( - ref: ref, isEdit: isEdit, userId: id, initialUsername: existingUsername, @@ -51,15 +52,13 @@ void showDeleteUserDialog( ).show(context); } -class _UserForm extends HookWidget { - final WidgetRef ref; +class _UserForm extends HookConsumerWidget { final bool isEdit; final String? userId; final String? initialUsername; final List? initialRoles; const _UserForm({ - required this.ref, required this.isEdit, this.userId, this.initialUsername, @@ -67,12 +66,13 @@ class _UserForm extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final usernameController = useTextEditingController( text: initialUsername ?? '', ); final passwordController = useTextEditingController(); final selectedRoles = useState>((initialRoles ?? []).toSet()); + final isLoading = useState(false); return SizedBox( width: 400, @@ -123,61 +123,76 @@ class _UserForm extends HookWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: isLoading.value + ? null + : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( - onPressed: () { - final username = usernameController.text.trim(); - final password = passwordController.text; - final roles = selectedRoles.value.toList(); + onPressed: isLoading.value + ? null + : () async { + final username = usernameController.text.trim(); + final password = passwordController.text; + final roles = selectedRoles.value.toList(); - if (username.isEmpty) return; - if (!isEdit && password.isEmpty) return; + if (username.isEmpty) return; + if (!isEdit && password.isEmpty) return; - Navigator.of(context).pop(); + isLoading.value = true; + try { + final client = ref.read(userServiceProvider); + final GrpcResult result; + if (isEdit) { + result = await callGrpcEndpoint( + () => client.updateUser( + UpdateUserRequest( + id: userId, + username: username, + password: password, + roles: roles, + ), + ), + ); + } else { + result = await callGrpcEndpoint( + () => client.createUser( + CreateUserRequest( + username: username, + password: password, + roles: roles, + ), + ), + ); + } - ConfirmDialog.info( - title: isEdit ? 'Update User' : 'Create User', - message: isEdit - ? Text('Save changes to "$username"?') - : Text('Create user "$username"?'), - confirmText: isEdit ? 'Save' : 'Create', - onConfirmAsyncGrpc: () async { - final client = ref.read(userServiceProvider); - if (isEdit) { - return await callGrpcEndpoint( - () => client.updateUser( - UpdateUserRequest( - id: userId, - username: username, - password: password, - roles: roles, - ), - ), - ); - } else { - return await callGrpcEndpoint( - () => client.createUser( - CreateUserRequest( - username: username, - password: password, - roles: roles, - ), - ), - ); - } - }, - showResultDialog: true, - successMessage: Text( - isEdit - ? '"$username" updated successfully' - : '"$username" created successfully', - ), - ).show(context); - }, - child: Text(isEdit ? 'Save' : 'Create'), + if (context.mounted) { + Navigator.of(context).pop(); + switch (result) { + case GrpcSuccess(): + SnackBarDialog.success( + message: isEdit + ? '"$username" updated successfully' + : '"$username" created successfully', + ).show(context); + case GrpcFailure(): + SnackBarDialog.fromGrpcStatus( + result: result, + ).show(context); + } + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? 'Save' : 'Create'), ), ], ), diff --git a/client/lib/widgets/dialogs/toast_overlay.dart b/client/lib/widgets/dialogs/toast_overlay.dart new file mode 100644 index 0000000..015cb59 --- /dev/null +++ b/client/lib/widgets/dialogs/toast_overlay.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:time_keeper/colors.dart'; +import 'package:time_keeper/widgets/dialogs/base_dialog.dart'; + +class ToastOverlay { + static OverlayEntry? _currentEntry; + static Timer? _dismissTimer; + + static void show( + BuildContext context, { + required String title, + required String message, + required DialogType type, + Duration duration = const Duration(seconds: 3), + }) { + // Remove existing toast + _dismiss(); + + final overlay = Overlay.of(context); + + final entry = OverlayEntry( + builder: (context) => _ToastWidget( + title: title, + message: message, + type: type, + onDismiss: _dismiss, + ), + ); + + _currentEntry = entry; + overlay.insert(entry); + + _dismissTimer = Timer(duration, _dismiss); + } + + static void success( + BuildContext context, { + required String title, + required String message, + Duration duration = const Duration(seconds: 3), + }) { + show( + context, + title: title, + message: message, + type: DialogType.success, + duration: duration, + ); + } + + static void error( + BuildContext context, { + required String title, + required String message, + Duration duration = const Duration(seconds: 3), + }) { + show( + context, + title: title, + message: message, + type: DialogType.error, + duration: duration, + ); + } + + static void info( + BuildContext context, { + required String title, + required String message, + Duration duration = const Duration(seconds: 3), + }) { + show( + context, + title: title, + message: message, + type: DialogType.info, + duration: duration, + ); + } + + static void warn( + BuildContext context, { + required String title, + required String message, + Duration duration = const Duration(seconds: 3), + }) { + show( + context, + title: title, + message: message, + type: DialogType.warn, + duration: duration, + ); + } + + static void _dismiss() { + _dismissTimer?.cancel(); + _dismissTimer = null; + _currentEntry?.remove(); + _currentEntry = null; + } +} + +class _ToastWidget extends StatefulWidget { + final String title; + final String message; + final DialogType type; + final VoidCallback onDismiss; + + const _ToastWidget({ + required this.title, + required this.message, + required this.type, + required this.onDismiss, + }); + + @override + State<_ToastWidget> createState() => _ToastWidgetState(); +} + +class _ToastWidgetState extends State<_ToastWidget> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fadeAnimation; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + _slideAnimation = Tween( + begin: const Offset(0, -0.3), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Color get _bannerColor { + switch (widget.type) { + case DialogType.error: + return supportErrorColor; + case DialogType.success: + return supportSuccessColor; + case DialogType.warn: + return supportWarningColor; + case DialogType.info: + return supportInfoColor; + } + } + + @override + Widget build(BuildContext context) { + const radius = 12.0; + + return Positioned( + top: 40, + left: 0, + right: 0, + child: Center( + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Material( + color: Colors.transparent, + child: GestureDetector( + onTap: widget.onDismiss, + child: Container( + constraints: const BoxConstraints( + minWidth: 300, + maxWidth: 500, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(radius), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Banner + Container( + color: _bannerColor, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + Icon( + widget.type.icon, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + // Body + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + widget.message, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/client/linux/flutter/generated_plugin_registrant.cc b/client/linux/flutter/generated_plugin_registrant.cc index e71a16d..c8f3dcc 100644 --- a/client/linux/flutter/generated_plugin_registrant.cc +++ b/client/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/client/linux/flutter/generated_plugins.cmake b/client/linux/flutter/generated_plugins.cmake index 2e1de87..00303ac 100644 --- a/client/linux/flutter/generated_plugins.cmake +++ b/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + screen_retriever_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 438e7e5..5e7b670 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,13 @@ import FlutterMacOS import Foundation import file_picker +import screen_retriever_macos import shared_preferences_foundation +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/client/pubspec.lock b/client/pubspec.lock index a07bcbb..b43a3c0 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -768,6 +768,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" shared_preferences: dependency: "direct main" description: @@ -1061,6 +1101,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" xdg_directories: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 97a2f21..85c7a16 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: fixnum: ^1.1.1 table_calendar: ^3.2.0 fl_chart: ^1.1.1 + window_manager: ^0.4.3 dev_dependencies: flutter_test: diff --git a/client/windows/flutter/generated_plugin_registrant.cc b/client/windows/flutter/generated_plugin_registrant.cc index 8b6d468..c6fe39a 100644 --- a/client/windows/flutter/generated_plugin_registrant.cc +++ b/client/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/client/windows/flutter/generated_plugins.cmake b/client/windows/flutter/generated_plugins.cmake index b93c4c3..5e3bc3d 100644 --- a/client/windows/flutter/generated_plugins.cmake +++ b/client/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + screen_retriever_windows + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/protos/api/api.proto b/protos/api/api.proto index aeea3fd..a207f61 100644 --- a/protos/api/api.proto +++ b/protos/api/api.proto @@ -6,7 +6,7 @@ import public "api/location.proto"; import public "api/schedule.proto"; import public "api/session.proto"; import public "api/settings.proto"; -import public "api/stats.proto"; +import public "api/statistics.proto"; import public "api/team_member.proto"; import public "api/user.proto"; diff --git a/protos/api/stats.proto b/protos/api/statistics.proto similarity index 95% rename from protos/api/stats.proto rename to protos/api/statistics.proto index acbe256..1454d4a 100644 --- a/protos/api/stats.proto +++ b/protos/api/statistics.proto @@ -23,6 +23,6 @@ message GetLeaderboardResponse { repeated LeaderboardEntry entries = 1; } -service StatsService { +service StatisticsService { rpc GetLeaderboard(GetLeaderboardRequest) returns (GetLeaderboardResponse); } diff --git a/server/src/core/api.rs b/server/src/core/api.rs index d81e76e..b1a5698 100644 --- a/server/src/core/api.rs +++ b/server/src/core/api.rs @@ -11,12 +11,12 @@ use crate::{ generated::api::{ health_service_server::HealthServiceServer, location_service_server::LocationServiceServer, schedule_service_server::ScheduleServiceServer, session_service_server::SessionServiceServer, - settings_service_server::SettingsServiceServer, stats_service_server::StatsServiceServer, + settings_service_server::SettingsServiceServer, statistics_service_server::StatisticsServiceServer, team_member_service_server::TeamMemberServiceServer, user_service_server::UserServiceServer, }, modules::{ health::HealthApi, location::LocationApi, schedule::ScheduleApi, session::SessionApi, settings::SettingsApi, - stats::StatsApi, team_member::TeamMemberApi, user::UserApi, + statistics::StatisticsApi, team_member::TeamMemberApi, user::UserApi, }, }; @@ -57,7 +57,7 @@ impl Api { .add_service(SessionServiceServer::with_interceptor(SessionApi {}, auth_interceptor)) .add_service(SettingsServiceServer::with_interceptor(SettingsApi {}, auth_interceptor)) .add_service(LocationServiceServer::with_interceptor(LocationApi {}, auth_interceptor)) - .add_service(StatsServiceServer::with_interceptor(StatsApi {}, auth_interceptor)); + .add_service(StatisticsServiceServer::with_interceptor(StatisticsApi {}, auth_interceptor)); match router .serve_with_shutdown(self.addr, async move { diff --git a/server/src/generated/tk.api.rs b/server/src/generated/tk.api.rs index a52d428..81d0a85 100644 --- a/server/src/generated/tk.api.rs +++ b/server/src/generated/tk.api.rs @@ -2307,7 +2307,7 @@ pub struct GetLeaderboardResponse { pub entries: ::prost::alloc::vec::Vec, } /// Generated client implementations. -pub mod stats_service_client { +pub mod statistics_service_client { #![allow( unused_variables, dead_code, @@ -2318,10 +2318,10 @@ pub mod stats_service_client { use tonic::codegen::*; use tonic::codegen::http::Uri; #[derive(Debug, Clone)] - pub struct StatsServiceClient { + pub struct StatisticsServiceClient { inner: tonic::client::Grpc, } - impl StatsServiceClient { + impl StatisticsServiceClient { /// Attempt to create a new client by connecting to a given endpoint. pub async fn connect(dst: D) -> Result where @@ -2332,7 +2332,7 @@ pub mod stats_service_client { Ok(Self::new(conn)) } } - impl StatsServiceClient + impl StatisticsServiceClient where T: tonic::client::GrpcService, T::Error: Into, @@ -2350,7 +2350,7 @@ pub mod stats_service_client { pub fn with_interceptor( inner: T, interceptor: F, - ) -> StatsServiceClient> + ) -> StatisticsServiceClient> where F: tonic::service::Interceptor, T::ResponseBody: Default, @@ -2364,7 +2364,7 @@ pub mod stats_service_client { http::Request, >>::Error: Into + std::marker::Send + std::marker::Sync, { - StatsServiceClient::new(InterceptedService::new(inner, interceptor)) + StatisticsServiceClient::new(InterceptedService::new(inner, interceptor)) } /// Compress requests with the given encoding. /// @@ -2414,17 +2414,17 @@ pub mod stats_service_client { })?; let codec = tonic_prost::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/tk.api.StatsService/GetLeaderboard", + "/tk.api.StatisticsService/GetLeaderboard", ); let mut req = request.into_request(); req.extensions_mut() - .insert(GrpcMethod::new("tk.api.StatsService", "GetLeaderboard")); + .insert(GrpcMethod::new("tk.api.StatisticsService", "GetLeaderboard")); self.inner.unary(req, path, codec).await } } } /// Generated server implementations. -pub mod stats_service_server { +pub mod statistics_service_server { #![allow( unused_variables, dead_code, @@ -2433,9 +2433,9 @@ pub mod stats_service_server { clippy::let_unit_value, )] use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with StatsServiceServer. + /// Generated trait containing gRPC methods that should be implemented for use with StatisticsServiceServer. #[async_trait] - pub trait StatsService: std::marker::Send + std::marker::Sync + 'static { + pub trait StatisticsService: std::marker::Send + std::marker::Sync + 'static { async fn get_leaderboard( &self, request: tonic::Request, @@ -2445,14 +2445,14 @@ pub mod stats_service_server { >; } #[derive(Debug)] - pub struct StatsServiceServer { + pub struct StatisticsServiceServer { inner: Arc, accept_compression_encodings: EnabledCompressionEncodings, send_compression_encodings: EnabledCompressionEncodings, max_decoding_message_size: Option, max_encoding_message_size: Option, } - impl StatsServiceServer { + impl StatisticsServiceServer { pub fn new(inner: T) -> Self { Self::from_arc(Arc::new(inner)) } @@ -2503,9 +2503,9 @@ pub mod stats_service_server { self } } - impl tonic::codegen::Service> for StatsServiceServer + impl tonic::codegen::Service> for StatisticsServiceServer where - T: StatsService, + T: StatisticsService, B: Body + std::marker::Send + 'static, B::Error: Into + std::marker::Send + 'static, { @@ -2520,11 +2520,11 @@ pub mod stats_service_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { - "/tk.api.StatsService/GetLeaderboard" => { + "/tk.api.StatisticsService/GetLeaderboard" => { #[allow(non_camel_case_types)] - struct GetLeaderboardSvc(pub Arc); + struct GetLeaderboardSvc(pub Arc); impl< - T: StatsService, + T: StatisticsService, > tonic::server::UnaryService for GetLeaderboardSvc { type Response = super::GetLeaderboardResponse; @@ -2538,7 +2538,8 @@ pub mod stats_service_server { ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::get_leaderboard(&inner, request).await + ::get_leaderboard(&inner, request) + .await }; Box::pin(fut) } @@ -2587,7 +2588,7 @@ pub mod stats_service_server { } } } - impl Clone for StatsServiceServer { + impl Clone for StatisticsServiceServer { fn clone(&self) -> Self { let inner = self.inner.clone(); Self { @@ -2600,8 +2601,8 @@ pub mod stats_service_server { } } /// Generated gRPC service name - pub const SERVICE_NAME: &str = "tk.api.StatsService"; - impl tonic::server::NamedService for StatsServiceServer { + pub const SERVICE_NAME: &str = "tk.api.StatisticsService"; + impl tonic::server::NamedService for StatisticsServiceServer { const NAME: &'static str = SERVICE_NAME; } } diff --git a/server/src/modules/mod.rs b/server/src/modules/mod.rs index ff50ef9..133518b 100644 --- a/server/src/modules/mod.rs +++ b/server/src/modules/mod.rs @@ -4,6 +4,6 @@ pub mod schedule; pub mod secret; pub mod session; pub mod settings; -pub mod stats; +pub mod statistics; pub mod team_member; pub mod user; diff --git a/server/src/modules/stats/api.rs b/server/src/modules/statistics/api.rs similarity index 97% rename from server/src/modules/stats/api.rs rename to server/src/modules/statistics/api.rs index a99def7..99fbee2 100644 --- a/server/src/modules/stats/api.rs +++ b/server/src/modules/statistics/api.rs @@ -6,14 +6,15 @@ use tonic::{Request, Response, Result, Status}; use crate::{ generated::{ api::{ - GetLeaderboardRequest, GetLeaderboardResponse, HoursBucket, LeaderboardEntry, stats_service_server::StatsService, + GetLeaderboardRequest, GetLeaderboardResponse, HoursBucket, LeaderboardEntry, + statistics_service_server::StatisticsService, }, db::{Session, TeamMember, TeamMemberSession}, }, modules::{session::SessionRepository, team_member::TeamMemberRepository}, }; -pub struct StatsApi; +pub struct StatisticsApi; /// Compute regular and overtime seconds for a single member session. /// @@ -91,7 +92,7 @@ impl MemberAccumulator { } #[tonic::async_trait] -impl StatsService for StatsApi { +impl StatisticsService for StatisticsApi { async fn get_leaderboard( &self, _request: Request, diff --git a/server/src/modules/stats/mod.rs b/server/src/modules/statistics/mod.rs similarity index 100% rename from server/src/modules/stats/mod.rs rename to server/src/modules/statistics/mod.rs