diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af0f074..e11432e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,18 +19,14 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.parse.outputs.version }} - flutter_version: ${{ steps.parse.outputs.flutter_version }} steps: - uses: actions/checkout@v4 - name: Parse vars.yml id: parse run: | version=$(grep 'name: tk_version' vars.yml -A1 | grep 'value:' | awk '{print $2}') - flutter_version=$(grep 'name: flutter_version' vars.yml -A1 | grep 'value:' | awk '{print $2}') echo "version=$version" >> "$GITHUB_OUTPUT" - echo "flutter_version=$flutter_version" >> "$GITHUB_OUTPUT" echo "Version: $version" - echo "Flutter: $flutter_version" # ── Build Flutter web client ──────────────────────────────────────── build-web-client: @@ -41,7 +37,6 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: ${{ needs.read-vars.outputs.flutter_version }} channel: stable - name: Generate Flutter Icons working-directory: client @@ -187,7 +182,6 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: ${{ needs.read-vars.outputs.flutter_version }} channel: stable # Android needs Java diff --git a/.github/workflows/qc.yml b/.github/workflows/qc.yml index f638930..dc2abfc 100644 --- a/.github/workflows/qc.yml +++ b/.github/workflows/qc.yml @@ -11,20 +11,6 @@ on: - master jobs: - read-vars: - name: Read Variables - runs-on: ubuntu-latest - outputs: - flutter_version: ${{ steps.parse.outputs.flutter_version }} - steps: - - uses: actions/checkout@v4 - - name: Parse vars.yml - id: parse - run: | - flutter_version=$(grep 'name: flutter_version' vars.yml -A1 | grep 'value:' | awk '{print $2}') - echo "flutter_version=$flutter_version" >> "$GITHUB_OUTPUT" - echo "Flutter: $flutter_version" - protobuf-check: name: Protobuf Linting runs-on: ubuntu-latest @@ -62,7 +48,6 @@ jobs: run: cargo clippy --all-targets -- -D warnings flutter-check: - needs: [read-vars] runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -70,7 +55,6 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: ${{ needs.read-vars.outputs.flutter_version }} channel: stable - run: flutter --version - name: Get dependencies diff --git a/Cargo.lock b/Cargo.lock index 7bf37c0..1206c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "clap" version = "4.5.57" @@ -967,6 +977,19 @@ dependencies = [ "cc", ] +[[package]] +name = "icalendar" +version = "0.17.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3b69b799a03e059f6dc984c25a8bf847d8ca4cbddb079c39ede7b3d24854c3" +dependencies = [ + "chrono", + "iso8601", + "nom", + "nom-language", + "uuid", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1000,6 +1023,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1185,6 +1217,24 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1376,6 +1426,24 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -1870,9 +1938,11 @@ dependencies = [ "async-stream", "axum", "chrono", + "chrono-tz", "clap", "dashmap", "database", + "icalendar", "jsonwebtoken", "log", "log4rs", @@ -1941,6 +2011,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" diff --git a/client/lib/base/app_bar/app_bar.dart b/client/lib/base/app_bar/app_bar.dart index 5302b34..4e5e3a9 100644 --- a/client/lib/base/app_bar/app_bar.dart +++ b/client/lib/base/app_bar/app_bar.dart @@ -5,7 +5,6 @@ import 'package:time_keeper/base/app_bar/login_action.dart'; import 'package:time_keeper/base/app_bar/settings_action.dart'; import 'package:time_keeper/base/app_bar/theme_action.dart'; import 'package:time_keeper/colors.dart'; -import 'package:time_keeper/providers/auth_provider.dart'; import 'package:time_keeper/providers/health_provider.dart'; import 'package:time_keeper/router/app_routes.dart'; @@ -32,33 +31,54 @@ class BaseAppBar extends ConsumerWidget implements PreferredSizeWidget { ); } + final routeName = state.topRoute?.name; + if (routeName != null) { + for (final route in AppRoute.values) { + if (route.name == routeName) { + return Text( + route.name.toUpperCase(), + style: TextStyle(fontWeight: FontWeight.bold), + ); + } + } + } + return null; } - Widget _leading(BuildContext context, String username) { - // Show home button only when not on home page + Widget _leading(BuildContext context) { final isHomePage = state.matchedLocation == '/'; - if (isHomePage) { - return Center( - child: Text(username, style: TextStyle(fontWeight: FontWeight.bold)), - ); // Hide button on home page - } - - return IconButton( - icon: Icon(Icons.home), - onPressed: () => AppRoute.kiosk.go(context), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isHomePage) + IconButton( + icon: Icon(Icons.home), + onPressed: () => AppRoute.kiosk.go(context), + ), + IconButton( + icon: Icon(Icons.leaderboard), + tooltip: 'Leaderboard', + onPressed: () => AppRoute.leaderboard.go(context), + ), + IconButton( + icon: Icon(Icons.calendar_month), + tooltip: 'Calendar', + onPressed: () => AppRoute.calendar.go(context), + ), + ], ); } @override Widget build(BuildContext context, WidgetRef ref) { final isConnected = ref.watch(isConnectedProvider).value ?? false; - final username = ref.watch(usernameProvider); return AppBar( backgroundColor: isConnected ? null : supportErrorColor, - leading: _leading(context, username ?? ''), + leadingWidth: 120, + leading: _leading(context), title: _title(isConnected, ref), actions: _actions(), ); diff --git a/client/lib/base/base_rail.dart b/client/lib/base/base_rail.dart index 037ae17..4ed76b6 100644 --- a/client/lib/base/base_rail.dart +++ b/client/lib/base/base_rail.dart @@ -55,8 +55,16 @@ class BaseRail extends HookConsumerWidget { label: Text('Team'), ), NavigationRailDestination( - icon: Icon(Icons.help), - label: Text('Help'), + icon: Icon(Icons.event_note), + label: Text('Sessions'), + ), + NavigationRailDestination( + icon: Icon(Icons.location_on), + label: Text('Locations'), + ), + NavigationRailDestination( + icon: Icon(Icons.analytics), + label: Text('Stats'), ), ], ), diff --git a/client/lib/generated/api/api.pb.dart b/client/lib/generated/api/api.pb.dart index b058edb..3a3d6e0 100644 --- a/client/lib/generated/api/api.pb.dart +++ b/client/lib/generated/api/api.pb.dart @@ -20,6 +20,7 @@ export 'location.pb.dart'; export 'schedule.pb.dart'; export 'session.pb.dart'; export 'settings.pb.dart'; +export 'stats.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 3fad394..5ce16a4 100644 --- a/client/lib/generated/api/api.pbenum.dart +++ b/client/lib/generated/api/api.pbenum.dart @@ -14,5 +14,6 @@ export 'location.pbenum.dart'; export 'schedule.pbenum.dart'; export 'session.pbenum.dart'; export 'settings.pbenum.dart'; +export 'stats.pbenum.dart'; export 'team_member.pbenum.dart'; export 'user.pbenum.dart'; diff --git a/client/lib/generated/api/location.pb.dart b/client/lib/generated/api/location.pb.dart index 9264088..d8a278b 100644 --- a/client/lib/generated/api/location.pb.dart +++ b/client/lib/generated/api/location.pb.dart @@ -277,6 +277,300 @@ class StreamLocationsResponse extends $pb.GeneratedMessage { void clearSyncType() => $_clearField(2); } +class CreateLocationRequest extends $pb.GeneratedMessage { + factory CreateLocationRequest({ + $core.String? location, + }) { + final result = create(); + if (location != null) result.location = location; + return result; + } + + CreateLocationRequest._(); + + factory CreateLocationRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CreateLocationRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CreateLocationRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'location') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateLocationRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateLocationRequest copyWith( + void Function(CreateLocationRequest) updates) => + super.copyWith((message) => updates(message as CreateLocationRequest)) + as CreateLocationRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CreateLocationRequest create() => CreateLocationRequest._(); + @$core.override + CreateLocationRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CreateLocationRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CreateLocationRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get location => $_getSZ(0); + @$pb.TagNumber(1) + set location($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasLocation() => $_has(0); + @$pb.TagNumber(1) + void clearLocation() => $_clearField(1); +} + +class CreateLocationResponse extends $pb.GeneratedMessage { + factory CreateLocationResponse() => create(); + + CreateLocationResponse._(); + + factory CreateLocationResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CreateLocationResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CreateLocationResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateLocationResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateLocationResponse copyWith( + void Function(CreateLocationResponse) updates) => + super.copyWith((message) => updates(message as CreateLocationResponse)) + as CreateLocationResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CreateLocationResponse create() => CreateLocationResponse._(); + @$core.override + CreateLocationResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CreateLocationResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CreateLocationResponse? _defaultInstance; +} + +class UpdateLocationRequest extends $pb.GeneratedMessage { + factory UpdateLocationRequest({ + $core.String? id, + $core.String? location, + }) { + final result = create(); + if (id != null) result.id = id; + if (location != null) result.location = location; + return result; + } + + UpdateLocationRequest._(); + + factory UpdateLocationRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory UpdateLocationRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'UpdateLocationRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'id') + ..aOS(2, _omitFieldNames ? '' : 'location') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateLocationRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateLocationRequest copyWith( + void Function(UpdateLocationRequest) updates) => + super.copyWith((message) => updates(message as UpdateLocationRequest)) + as UpdateLocationRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static UpdateLocationRequest create() => UpdateLocationRequest._(); + @$core.override + UpdateLocationRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static UpdateLocationRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static UpdateLocationRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get id => $_getSZ(0); + @$pb.TagNumber(1) + set id($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get location => $_getSZ(1); + @$pb.TagNumber(2) + set location($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasLocation() => $_has(1); + @$pb.TagNumber(2) + void clearLocation() => $_clearField(2); +} + +class UpdateLocationResponse extends $pb.GeneratedMessage { + factory UpdateLocationResponse() => create(); + + UpdateLocationResponse._(); + + factory UpdateLocationResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory UpdateLocationResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'UpdateLocationResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateLocationResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateLocationResponse copyWith( + void Function(UpdateLocationResponse) updates) => + super.copyWith((message) => updates(message as UpdateLocationResponse)) + as UpdateLocationResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static UpdateLocationResponse create() => UpdateLocationResponse._(); + @$core.override + UpdateLocationResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static UpdateLocationResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static UpdateLocationResponse? _defaultInstance; +} + +class DeleteLocationRequest extends $pb.GeneratedMessage { + factory DeleteLocationRequest({ + $core.String? id, + }) { + final result = create(); + if (id != null) result.id = id; + return result; + } + + DeleteLocationRequest._(); + + factory DeleteLocationRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory DeleteLocationRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'DeleteLocationRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'id') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteLocationRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteLocationRequest copyWith( + void Function(DeleteLocationRequest) updates) => + super.copyWith((message) => updates(message as DeleteLocationRequest)) + as DeleteLocationRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeleteLocationRequest create() => DeleteLocationRequest._(); + @$core.override + DeleteLocationRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static DeleteLocationRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static DeleteLocationRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get id => $_getSZ(0); + @$pb.TagNumber(1) + set id($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => $_clearField(1); +} + +class DeleteLocationResponse extends $pb.GeneratedMessage { + factory DeleteLocationResponse() => create(); + + DeleteLocationResponse._(); + + factory DeleteLocationResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory DeleteLocationResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'DeleteLocationResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteLocationResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteLocationResponse copyWith( + void Function(DeleteLocationResponse) updates) => + super.copyWith((message) => updates(message as DeleteLocationResponse)) + as DeleteLocationResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeleteLocationResponse create() => DeleteLocationResponse._(); + @$core.override + DeleteLocationResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static DeleteLocationResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static DeleteLocationResponse? _defaultInstance; +} + const $core.bool _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); const $core.bool _omitMessageNames = diff --git a/client/lib/generated/api/location.pbgrpc.dart b/client/lib/generated/api/location.pbgrpc.dart index 3dc1f66..6c9e206 100644 --- a/client/lib/generated/api/location.pbgrpc.dart +++ b/client/lib/generated/api/location.pbgrpc.dart @@ -48,6 +48,27 @@ class LocationServiceClient extends $grpc.Client { options: options); } + $grpc.ResponseFuture<$0.CreateLocationResponse> createLocation( + $0.CreateLocationRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$createLocation, request, options: options); + } + + $grpc.ResponseFuture<$0.UpdateLocationResponse> updateLocation( + $0.UpdateLocationRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$updateLocation, request, options: options); + } + + $grpc.ResponseFuture<$0.DeleteLocationResponse> deleteLocation( + $0.DeleteLocationRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$deleteLocation, request, options: options); + } + // method descriptors static final _$getLocations = @@ -60,6 +81,21 @@ class LocationServiceClient extends $grpc.Client { '/tk.api.LocationService/StreamLocations', ($0.StreamLocationsRequest value) => value.writeToBuffer(), $0.StreamLocationsResponse.fromBuffer); + static final _$createLocation = + $grpc.ClientMethod<$0.CreateLocationRequest, $0.CreateLocationResponse>( + '/tk.api.LocationService/CreateLocation', + ($0.CreateLocationRequest value) => value.writeToBuffer(), + $0.CreateLocationResponse.fromBuffer); + static final _$updateLocation = + $grpc.ClientMethod<$0.UpdateLocationRequest, $0.UpdateLocationResponse>( + '/tk.api.LocationService/UpdateLocation', + ($0.UpdateLocationRequest value) => value.writeToBuffer(), + $0.UpdateLocationResponse.fromBuffer); + static final _$deleteLocation = + $grpc.ClientMethod<$0.DeleteLocationRequest, $0.DeleteLocationResponse>( + '/tk.api.LocationService/DeleteLocation', + ($0.DeleteLocationRequest value) => value.writeToBuffer(), + $0.DeleteLocationResponse.fromBuffer); } @$pb.GrpcServiceName('tk.api.LocationService') @@ -85,6 +121,33 @@ abstract class LocationServiceBase extends $grpc.Service { ($core.List<$core.int> value) => $0.StreamLocationsRequest.fromBuffer(value), ($0.StreamLocationsResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.CreateLocationRequest, + $0.CreateLocationResponse>( + 'CreateLocation', + createLocation_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.CreateLocationRequest.fromBuffer(value), + ($0.CreateLocationResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.UpdateLocationRequest, + $0.UpdateLocationResponse>( + 'UpdateLocation', + updateLocation_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.UpdateLocationRequest.fromBuffer(value), + ($0.UpdateLocationResponse value) => value.writeToBuffer())); + $addMethod($grpc.ServiceMethod<$0.DeleteLocationRequest, + $0.DeleteLocationResponse>( + 'DeleteLocation', + deleteLocation_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.DeleteLocationRequest.fromBuffer(value), + ($0.DeleteLocationResponse value) => value.writeToBuffer())); } $async.Future<$0.GetLocationsResponse> getLocations_Pre( @@ -104,4 +167,31 @@ abstract class LocationServiceBase extends $grpc.Service { $async.Stream<$0.StreamLocationsResponse> streamLocations( $grpc.ServiceCall call, $0.StreamLocationsRequest request); + + $async.Future<$0.CreateLocationResponse> createLocation_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.CreateLocationRequest> $request) async { + return createLocation($call, await $request); + } + + $async.Future<$0.CreateLocationResponse> createLocation( + $grpc.ServiceCall call, $0.CreateLocationRequest request); + + $async.Future<$0.UpdateLocationResponse> updateLocation_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.UpdateLocationRequest> $request) async { + return updateLocation($call, await $request); + } + + $async.Future<$0.UpdateLocationResponse> updateLocation( + $grpc.ServiceCall call, $0.UpdateLocationRequest request); + + $async.Future<$0.DeleteLocationResponse> deleteLocation_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.DeleteLocationRequest> $request) async { + return deleteLocation($call, await $request); + } + + $async.Future<$0.DeleteLocationResponse> deleteLocation( + $grpc.ServiceCall call, $0.DeleteLocationRequest request); } diff --git a/client/lib/generated/api/location.pbjson.dart b/client/lib/generated/api/location.pbjson.dart index 56c8399..88436b0 100644 --- a/client/lib/generated/api/location.pbjson.dart +++ b/client/lib/generated/api/location.pbjson.dart @@ -102,3 +102,69 @@ final $typed_data.Uint8List streamLocationsResponseDescriptor = $convert.base64D 'ChdTdHJlYW1Mb2NhdGlvbnNSZXNwb25zZRI2Cglsb2NhdGlvbnMYASADKAsyGC50ay5hcGkuTG' '9jYXRpb25SZXNwb25zZVIJbG9jYXRpb25zEjAKCXN5bmNfdHlwZRgCIAEoDjITLnRrLmNvbW1v' 'bi5TeW5jVHlwZVIIc3luY1R5cGU='); + +@$core.Deprecated('Use createLocationRequestDescriptor instead') +const CreateLocationRequest$json = { + '1': 'CreateLocationRequest', + '2': [ + {'1': 'location', '3': 1, '4': 1, '5': 9, '10': 'location'}, + ], +}; + +/// Descriptor for `CreateLocationRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List createLocationRequestDescriptor = + $convert.base64Decode( + 'ChVDcmVhdGVMb2NhdGlvblJlcXVlc3QSGgoIbG9jYXRpb24YASABKAlSCGxvY2F0aW9u'); + +@$core.Deprecated('Use createLocationResponseDescriptor instead') +const CreateLocationResponse$json = { + '1': 'CreateLocationResponse', +}; + +/// Descriptor for `CreateLocationResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List createLocationResponseDescriptor = + $convert.base64Decode('ChZDcmVhdGVMb2NhdGlvblJlc3BvbnNl'); + +@$core.Deprecated('Use updateLocationRequestDescriptor instead') +const UpdateLocationRequest$json = { + '1': 'UpdateLocationRequest', + '2': [ + {'1': 'id', '3': 1, '4': 1, '5': 9, '10': 'id'}, + {'1': 'location', '3': 2, '4': 1, '5': 9, '10': 'location'}, + ], +}; + +/// Descriptor for `UpdateLocationRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List updateLocationRequestDescriptor = $convert.base64Decode( + 'ChVVcGRhdGVMb2NhdGlvblJlcXVlc3QSDgoCaWQYASABKAlSAmlkEhoKCGxvY2F0aW9uGAIgAS' + 'gJUghsb2NhdGlvbg=='); + +@$core.Deprecated('Use updateLocationResponseDescriptor instead') +const UpdateLocationResponse$json = { + '1': 'UpdateLocationResponse', +}; + +/// Descriptor for `UpdateLocationResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List updateLocationResponseDescriptor = + $convert.base64Decode('ChZVcGRhdGVMb2NhdGlvblJlc3BvbnNl'); + +@$core.Deprecated('Use deleteLocationRequestDescriptor instead') +const DeleteLocationRequest$json = { + '1': 'DeleteLocationRequest', + '2': [ + {'1': 'id', '3': 1, '4': 1, '5': 9, '10': 'id'}, + ], +}; + +/// Descriptor for `DeleteLocationRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List deleteLocationRequestDescriptor = $convert + .base64Decode('ChVEZWxldGVMb2NhdGlvblJlcXVlc3QSDgoCaWQYASABKAlSAmlk'); + +@$core.Deprecated('Use deleteLocationResponseDescriptor instead') +const DeleteLocationResponse$json = { + '1': 'DeleteLocationResponse', +}; + +/// Descriptor for `DeleteLocationResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List deleteLocationResponseDescriptor = + $convert.base64Decode('ChZEZWxldGVMb2NhdGlvblJlc3BvbnNl'); diff --git a/client/lib/generated/api/session.pb.dart b/client/lib/generated/api/session.pb.dart index c72a7f3..53f5927 100644 --- a/client/lib/generated/api/session.pb.dart +++ b/client/lib/generated/api/session.pb.dart @@ -14,7 +14,7 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; -import '../common/common.pbenum.dart' as $2; +import '../common/common.pb.dart' as $2; import '../db/db.pb.dart' as $1; export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; @@ -277,6 +277,369 @@ class StreamSessionsResponse extends $pb.GeneratedMessage { void clearSyncType() => $_clearField(2); } +class CreateSessionRequest extends $pb.GeneratedMessage { + factory CreateSessionRequest({ + $2.Timestamp? startTime, + $2.Timestamp? endTime, + $core.String? locationId, + }) { + final result = create(); + if (startTime != null) result.startTime = startTime; + if (endTime != null) result.endTime = endTime; + if (locationId != null) result.locationId = locationId; + return result; + } + + CreateSessionRequest._(); + + factory CreateSessionRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CreateSessionRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CreateSessionRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOM<$2.Timestamp>(1, _omitFieldNames ? '' : 'startTime', + subBuilder: $2.Timestamp.create) + ..aOM<$2.Timestamp>(2, _omitFieldNames ? '' : 'endTime', + subBuilder: $2.Timestamp.create) + ..aOS(3, _omitFieldNames ? '' : 'locationId') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateSessionRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateSessionRequest copyWith(void Function(CreateSessionRequest) updates) => + super.copyWith((message) => updates(message as CreateSessionRequest)) + as CreateSessionRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CreateSessionRequest create() => CreateSessionRequest._(); + @$core.override + CreateSessionRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CreateSessionRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CreateSessionRequest? _defaultInstance; + + @$pb.TagNumber(1) + $2.Timestamp get startTime => $_getN(0); + @$pb.TagNumber(1) + set startTime($2.Timestamp value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasStartTime() => $_has(0); + @$pb.TagNumber(1) + void clearStartTime() => $_clearField(1); + @$pb.TagNumber(1) + $2.Timestamp ensureStartTime() => $_ensure(0); + + @$pb.TagNumber(2) + $2.Timestamp get endTime => $_getN(1); + @$pb.TagNumber(2) + set endTime($2.Timestamp value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasEndTime() => $_has(1); + @$pb.TagNumber(2) + void clearEndTime() => $_clearField(2); + @$pb.TagNumber(2) + $2.Timestamp ensureEndTime() => $_ensure(1); + + @$pb.TagNumber(3) + $core.String get locationId => $_getSZ(2); + @$pb.TagNumber(3) + set locationId($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasLocationId() => $_has(2); + @$pb.TagNumber(3) + void clearLocationId() => $_clearField(3); +} + +class CreateSessionResponse extends $pb.GeneratedMessage { + factory CreateSessionResponse() => create(); + + CreateSessionResponse._(); + + factory CreateSessionResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CreateSessionResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CreateSessionResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateSessionResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CreateSessionResponse copyWith( + void Function(CreateSessionResponse) updates) => + super.copyWith((message) => updates(message as CreateSessionResponse)) + as CreateSessionResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CreateSessionResponse create() => CreateSessionResponse._(); + @$core.override + CreateSessionResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CreateSessionResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CreateSessionResponse? _defaultInstance; +} + +class UpdateSessionRequest extends $pb.GeneratedMessage { + factory UpdateSessionRequest({ + $core.String? id, + $2.Timestamp? startTime, + $2.Timestamp? endTime, + $core.String? locationId, + $core.bool? finished, + }) { + final result = create(); + if (id != null) result.id = id; + if (startTime != null) result.startTime = startTime; + if (endTime != null) result.endTime = endTime; + if (locationId != null) result.locationId = locationId; + if (finished != null) result.finished = finished; + return result; + } + + UpdateSessionRequest._(); + + factory UpdateSessionRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory UpdateSessionRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'UpdateSessionRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'id') + ..aOM<$2.Timestamp>(2, _omitFieldNames ? '' : 'startTime', + subBuilder: $2.Timestamp.create) + ..aOM<$2.Timestamp>(3, _omitFieldNames ? '' : 'endTime', + subBuilder: $2.Timestamp.create) + ..aOS(4, _omitFieldNames ? '' : 'locationId') + ..aOB(5, _omitFieldNames ? '' : 'finished') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateSessionRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateSessionRequest copyWith(void Function(UpdateSessionRequest) updates) => + super.copyWith((message) => updates(message as UpdateSessionRequest)) + as UpdateSessionRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static UpdateSessionRequest create() => UpdateSessionRequest._(); + @$core.override + UpdateSessionRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static UpdateSessionRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static UpdateSessionRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get id => $_getSZ(0); + @$pb.TagNumber(1) + set id($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => $_clearField(1); + + @$pb.TagNumber(2) + $2.Timestamp get startTime => $_getN(1); + @$pb.TagNumber(2) + set startTime($2.Timestamp value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasStartTime() => $_has(1); + @$pb.TagNumber(2) + void clearStartTime() => $_clearField(2); + @$pb.TagNumber(2) + $2.Timestamp ensureStartTime() => $_ensure(1); + + @$pb.TagNumber(3) + $2.Timestamp get endTime => $_getN(2); + @$pb.TagNumber(3) + set endTime($2.Timestamp value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasEndTime() => $_has(2); + @$pb.TagNumber(3) + void clearEndTime() => $_clearField(3); + @$pb.TagNumber(3) + $2.Timestamp ensureEndTime() => $_ensure(2); + + @$pb.TagNumber(4) + $core.String get locationId => $_getSZ(3); + @$pb.TagNumber(4) + set locationId($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasLocationId() => $_has(3); + @$pb.TagNumber(4) + void clearLocationId() => $_clearField(4); + + @$pb.TagNumber(5) + $core.bool get finished => $_getBF(4); + @$pb.TagNumber(5) + set finished($core.bool value) => $_setBool(4, value); + @$pb.TagNumber(5) + $core.bool hasFinished() => $_has(4); + @$pb.TagNumber(5) + void clearFinished() => $_clearField(5); +} + +class UpdateSessionResponse extends $pb.GeneratedMessage { + factory UpdateSessionResponse() => create(); + + UpdateSessionResponse._(); + + factory UpdateSessionResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory UpdateSessionResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'UpdateSessionResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateSessionResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + UpdateSessionResponse copyWith( + void Function(UpdateSessionResponse) updates) => + super.copyWith((message) => updates(message as UpdateSessionResponse)) + as UpdateSessionResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static UpdateSessionResponse create() => UpdateSessionResponse._(); + @$core.override + UpdateSessionResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static UpdateSessionResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static UpdateSessionResponse? _defaultInstance; +} + +class DeleteSessionRequest extends $pb.GeneratedMessage { + factory DeleteSessionRequest({ + $core.String? id, + }) { + final result = create(); + if (id != null) result.id = id; + return result; + } + + DeleteSessionRequest._(); + + factory DeleteSessionRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory DeleteSessionRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'DeleteSessionRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'id') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteSessionRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteSessionRequest copyWith(void Function(DeleteSessionRequest) updates) => + super.copyWith((message) => updates(message as DeleteSessionRequest)) + as DeleteSessionRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeleteSessionRequest create() => DeleteSessionRequest._(); + @$core.override + DeleteSessionRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static DeleteSessionRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static DeleteSessionRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get id => $_getSZ(0); + @$pb.TagNumber(1) + set id($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => $_clearField(1); +} + +class DeleteSessionResponse extends $pb.GeneratedMessage { + factory DeleteSessionResponse() => create(); + + DeleteSessionResponse._(); + + factory DeleteSessionResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory DeleteSessionResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'DeleteSessionResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteSessionResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + DeleteSessionResponse copyWith( + void Function(DeleteSessionResponse) updates) => + super.copyWith((message) => updates(message as DeleteSessionResponse)) + as DeleteSessionResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeleteSessionResponse create() => DeleteSessionResponse._(); + @$core.override + DeleteSessionResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static DeleteSessionResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static DeleteSessionResponse? _defaultInstance; +} + class CheckInOutRequest extends $pb.GeneratedMessage { factory CheckInOutRequest({ $core.String? teamMemberId, diff --git a/client/lib/generated/api/session.pbgrpc.dart b/client/lib/generated/api/session.pbgrpc.dart index 70ba896..2c9d462 100644 --- a/client/lib/generated/api/session.pbgrpc.dart +++ b/client/lib/generated/api/session.pbgrpc.dart @@ -48,6 +48,27 @@ class SessionServiceClient extends $grpc.Client { options: options); } + $grpc.ResponseFuture<$0.CreateSessionResponse> createSession( + $0.CreateSessionRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$createSession, request, options: options); + } + + $grpc.ResponseFuture<$0.UpdateSessionResponse> updateSession( + $0.UpdateSessionRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$updateSession, request, options: options); + } + + $grpc.ResponseFuture<$0.DeleteSessionResponse> deleteSession( + $0.DeleteSessionRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$deleteSession, request, options: options); + } + $grpc.ResponseFuture<$0.CheckInOutResponse> checkInOut( $0.CheckInOutRequest request, { $grpc.CallOptions? options, @@ -67,6 +88,21 @@ class SessionServiceClient extends $grpc.Client { '/tk.api.SessionService/StreamSessions', ($0.StreamSessionsRequest value) => value.writeToBuffer(), $0.StreamSessionsResponse.fromBuffer); + static final _$createSession = + $grpc.ClientMethod<$0.CreateSessionRequest, $0.CreateSessionResponse>( + '/tk.api.SessionService/CreateSession', + ($0.CreateSessionRequest value) => value.writeToBuffer(), + $0.CreateSessionResponse.fromBuffer); + static final _$updateSession = + $grpc.ClientMethod<$0.UpdateSessionRequest, $0.UpdateSessionResponse>( + '/tk.api.SessionService/UpdateSession', + ($0.UpdateSessionRequest value) => value.writeToBuffer(), + $0.UpdateSessionResponse.fromBuffer); + static final _$deleteSession = + $grpc.ClientMethod<$0.DeleteSessionRequest, $0.DeleteSessionResponse>( + '/tk.api.SessionService/DeleteSession', + ($0.DeleteSessionRequest value) => value.writeToBuffer(), + $0.DeleteSessionResponse.fromBuffer); static final _$checkInOut = $grpc.ClientMethod<$0.CheckInOutRequest, $0.CheckInOutResponse>( '/tk.api.SessionService/CheckInOut', @@ -97,6 +133,33 @@ abstract class SessionServiceBase extends $grpc.Service { ($core.List<$core.int> value) => $0.StreamSessionsRequest.fromBuffer(value), ($0.StreamSessionsResponse value) => value.writeToBuffer())); + $addMethod( + $grpc.ServiceMethod<$0.CreateSessionRequest, $0.CreateSessionResponse>( + 'CreateSession', + createSession_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.CreateSessionRequest.fromBuffer(value), + ($0.CreateSessionResponse value) => value.writeToBuffer())); + $addMethod( + $grpc.ServiceMethod<$0.UpdateSessionRequest, $0.UpdateSessionResponse>( + 'UpdateSession', + updateSession_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.UpdateSessionRequest.fromBuffer(value), + ($0.UpdateSessionResponse value) => value.writeToBuffer())); + $addMethod( + $grpc.ServiceMethod<$0.DeleteSessionRequest, $0.DeleteSessionResponse>( + 'DeleteSession', + deleteSession_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.DeleteSessionRequest.fromBuffer(value), + ($0.DeleteSessionResponse value) => value.writeToBuffer())); $addMethod($grpc.ServiceMethod<$0.CheckInOutRequest, $0.CheckInOutResponse>( 'CheckInOut', checkInOut_Pre, @@ -123,6 +186,33 @@ abstract class SessionServiceBase extends $grpc.Service { $async.Stream<$0.StreamSessionsResponse> streamSessions( $grpc.ServiceCall call, $0.StreamSessionsRequest request); + $async.Future<$0.CreateSessionResponse> createSession_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.CreateSessionRequest> $request) async { + return createSession($call, await $request); + } + + $async.Future<$0.CreateSessionResponse> createSession( + $grpc.ServiceCall call, $0.CreateSessionRequest request); + + $async.Future<$0.UpdateSessionResponse> updateSession_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.UpdateSessionRequest> $request) async { + return updateSession($call, await $request); + } + + $async.Future<$0.UpdateSessionResponse> updateSession( + $grpc.ServiceCall call, $0.UpdateSessionRequest request); + + $async.Future<$0.DeleteSessionResponse> deleteSession_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.DeleteSessionRequest> $request) async { + return deleteSession($call, await $request); + } + + $async.Future<$0.DeleteSessionResponse> deleteSession( + $grpc.ServiceCall call, $0.DeleteSessionRequest request); + $async.Future<$0.CheckInOutResponse> checkInOut_Pre($grpc.ServiceCall $call, $async.Future<$0.CheckInOutRequest> $request) async { return checkInOut($call, await $request); diff --git a/client/lib/generated/api/session.pbjson.dart b/client/lib/generated/api/session.pbjson.dart index dd26336..732d933 100644 --- a/client/lib/generated/api/session.pbjson.dart +++ b/client/lib/generated/api/session.pbjson.dart @@ -103,6 +103,108 @@ final $typed_data.Uint8List streamSessionsResponseDescriptor = $convert.base64De 'Npb25SZXNwb25zZVIIc2Vzc2lvbnMSMAoJc3luY190eXBlGAIgASgOMhMudGsuY29tbW9uLlN5' 'bmNUeXBlUghzeW5jVHlwZQ=='); +@$core.Deprecated('Use createSessionRequestDescriptor instead') +const CreateSessionRequest$json = { + '1': 'CreateSessionRequest', + '2': [ + { + '1': 'start_time', + '3': 1, + '4': 1, + '5': 11, + '6': '.tk.common.Timestamp', + '10': 'startTime' + }, + { + '1': 'end_time', + '3': 2, + '4': 1, + '5': 11, + '6': '.tk.common.Timestamp', + '10': 'endTime' + }, + {'1': 'location_id', '3': 3, '4': 1, '5': 9, '10': 'locationId'}, + ], +}; + +/// Descriptor for `CreateSessionRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List createSessionRequestDescriptor = $convert.base64Decode( + 'ChRDcmVhdGVTZXNzaW9uUmVxdWVzdBIzCgpzdGFydF90aW1lGAEgASgLMhQudGsuY29tbW9uLl' + 'RpbWVzdGFtcFIJc3RhcnRUaW1lEi8KCGVuZF90aW1lGAIgASgLMhQudGsuY29tbW9uLlRpbWVz' + 'dGFtcFIHZW5kVGltZRIfCgtsb2NhdGlvbl9pZBgDIAEoCVIKbG9jYXRpb25JZA=='); + +@$core.Deprecated('Use createSessionResponseDescriptor instead') +const CreateSessionResponse$json = { + '1': 'CreateSessionResponse', +}; + +/// Descriptor for `CreateSessionResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List createSessionResponseDescriptor = + $convert.base64Decode('ChVDcmVhdGVTZXNzaW9uUmVzcG9uc2U='); + +@$core.Deprecated('Use updateSessionRequestDescriptor instead') +const UpdateSessionRequest$json = { + '1': 'UpdateSessionRequest', + '2': [ + {'1': 'id', '3': 1, '4': 1, '5': 9, '10': 'id'}, + { + '1': 'start_time', + '3': 2, + '4': 1, + '5': 11, + '6': '.tk.common.Timestamp', + '10': 'startTime' + }, + { + '1': 'end_time', + '3': 3, + '4': 1, + '5': 11, + '6': '.tk.common.Timestamp', + '10': 'endTime' + }, + {'1': 'location_id', '3': 4, '4': 1, '5': 9, '10': 'locationId'}, + {'1': 'finished', '3': 5, '4': 1, '5': 8, '10': 'finished'}, + ], +}; + +/// Descriptor for `UpdateSessionRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List updateSessionRequestDescriptor = $convert.base64Decode( + 'ChRVcGRhdGVTZXNzaW9uUmVxdWVzdBIOCgJpZBgBIAEoCVICaWQSMwoKc3RhcnRfdGltZRgCIA' + 'EoCzIULnRrLmNvbW1vbi5UaW1lc3RhbXBSCXN0YXJ0VGltZRIvCghlbmRfdGltZRgDIAEoCzIU' + 'LnRrLmNvbW1vbi5UaW1lc3RhbXBSB2VuZFRpbWUSHwoLbG9jYXRpb25faWQYBCABKAlSCmxvY2' + 'F0aW9uSWQSGgoIZmluaXNoZWQYBSABKAhSCGZpbmlzaGVk'); + +@$core.Deprecated('Use updateSessionResponseDescriptor instead') +const UpdateSessionResponse$json = { + '1': 'UpdateSessionResponse', +}; + +/// Descriptor for `UpdateSessionResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List updateSessionResponseDescriptor = + $convert.base64Decode('ChVVcGRhdGVTZXNzaW9uUmVzcG9uc2U='); + +@$core.Deprecated('Use deleteSessionRequestDescriptor instead') +const DeleteSessionRequest$json = { + '1': 'DeleteSessionRequest', + '2': [ + {'1': 'id', '3': 1, '4': 1, '5': 9, '10': 'id'}, + ], +}; + +/// Descriptor for `DeleteSessionRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List deleteSessionRequestDescriptor = $convert + .base64Decode('ChREZWxldGVTZXNzaW9uUmVxdWVzdBIOCgJpZBgBIAEoCVICaWQ='); + +@$core.Deprecated('Use deleteSessionResponseDescriptor instead') +const DeleteSessionResponse$json = { + '1': 'DeleteSessionResponse', +}; + +/// Descriptor for `DeleteSessionResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List deleteSessionResponseDescriptor = + $convert.base64Decode('ChVEZWxldGVTZXNzaW9uUmVzcG9uc2U='); + @$core.Deprecated('Use checkInOutRequestDescriptor instead') const CheckInOutRequest$json = { '1': 'CheckInOutRequest', diff --git a/client/lib/generated/api/settings.pb.dart b/client/lib/generated/api/settings.pb.dart index c82608f..ef63502 100644 --- a/client/lib/generated/api/settings.pb.dart +++ b/client/lib/generated/api/settings.pb.dart @@ -209,6 +209,83 @@ class UpdateSettingsResponse extends $pb.GeneratedMessage { static UpdateSettingsResponse? _defaultInstance; } +class PurgeDatabaseRequest extends $pb.GeneratedMessage { + factory PurgeDatabaseRequest() => create(); + + PurgeDatabaseRequest._(); + + factory PurgeDatabaseRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory PurgeDatabaseRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'PurgeDatabaseRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + PurgeDatabaseRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + PurgeDatabaseRequest copyWith(void Function(PurgeDatabaseRequest) updates) => + super.copyWith((message) => updates(message as PurgeDatabaseRequest)) + as PurgeDatabaseRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static PurgeDatabaseRequest create() => PurgeDatabaseRequest._(); + @$core.override + PurgeDatabaseRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static PurgeDatabaseRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static PurgeDatabaseRequest? _defaultInstance; +} + +class PurgeDatabaseResponse extends $pb.GeneratedMessage { + factory PurgeDatabaseResponse() => create(); + + PurgeDatabaseResponse._(); + + factory PurgeDatabaseResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory PurgeDatabaseResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'PurgeDatabaseResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + PurgeDatabaseResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + PurgeDatabaseResponse copyWith( + void Function(PurgeDatabaseResponse) updates) => + super.copyWith((message) => updates(message as PurgeDatabaseResponse)) + as PurgeDatabaseResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static PurgeDatabaseResponse create() => PurgeDatabaseResponse._(); + @$core.override + PurgeDatabaseResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static PurgeDatabaseResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static PurgeDatabaseResponse? _defaultInstance; +} + const $core.bool _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); const $core.bool _omitMessageNames = diff --git a/client/lib/generated/api/settings.pbgrpc.dart b/client/lib/generated/api/settings.pbgrpc.dart index 60cb264..92335bb 100644 --- a/client/lib/generated/api/settings.pbgrpc.dart +++ b/client/lib/generated/api/settings.pbgrpc.dart @@ -46,6 +46,13 @@ class SettingsServiceClient extends $grpc.Client { return $createUnaryCall(_$updateSettings, request, options: options); } + $grpc.ResponseFuture<$0.PurgeDatabaseResponse> purgeDatabase( + $0.PurgeDatabaseRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$purgeDatabase, request, options: options); + } + // method descriptors static final _$getSettings = @@ -58,6 +65,11 @@ class SettingsServiceClient extends $grpc.Client { '/tk.api.SettingsService/UpdateSettings', ($0.UpdateSettingsRequest value) => value.writeToBuffer(), $0.UpdateSettingsResponse.fromBuffer); + static final _$purgeDatabase = + $grpc.ClientMethod<$0.PurgeDatabaseRequest, $0.PurgeDatabaseResponse>( + '/tk.api.SettingsService/PurgeDatabase', + ($0.PurgeDatabaseRequest value) => value.writeToBuffer(), + $0.PurgeDatabaseResponse.fromBuffer); } @$pb.GrpcServiceName('tk.api.SettingsService') @@ -83,6 +95,15 @@ abstract class SettingsServiceBase extends $grpc.Service { ($core.List<$core.int> value) => $0.UpdateSettingsRequest.fromBuffer(value), ($0.UpdateSettingsResponse value) => value.writeToBuffer())); + $addMethod( + $grpc.ServiceMethod<$0.PurgeDatabaseRequest, $0.PurgeDatabaseResponse>( + 'PurgeDatabase', + purgeDatabase_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.PurgeDatabaseRequest.fromBuffer(value), + ($0.PurgeDatabaseResponse value) => value.writeToBuffer())); } $async.Future<$0.GetSettingsResponse> getSettings_Pre($grpc.ServiceCall $call, @@ -101,4 +122,13 @@ abstract class SettingsServiceBase extends $grpc.Service { $async.Future<$0.UpdateSettingsResponse> updateSettings( $grpc.ServiceCall call, $0.UpdateSettingsRequest request); + + $async.Future<$0.PurgeDatabaseResponse> purgeDatabase_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.PurgeDatabaseRequest> $request) async { + return purgeDatabase($call, await $request); + } + + $async.Future<$0.PurgeDatabaseResponse> purgeDatabase( + $grpc.ServiceCall call, $0.PurgeDatabaseRequest request); } diff --git a/client/lib/generated/api/settings.pbjson.dart b/client/lib/generated/api/settings.pbjson.dart index d3a11b9..ff16547 100644 --- a/client/lib/generated/api/settings.pbjson.dart +++ b/client/lib/generated/api/settings.pbjson.dart @@ -71,3 +71,21 @@ const UpdateSettingsResponse$json = { /// Descriptor for `UpdateSettingsResponse`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List updateSettingsResponseDescriptor = $convert.base64Decode('ChZVcGRhdGVTZXR0aW5nc1Jlc3BvbnNl'); + +@$core.Deprecated('Use purgeDatabaseRequestDescriptor instead') +const PurgeDatabaseRequest$json = { + '1': 'PurgeDatabaseRequest', +}; + +/// Descriptor for `PurgeDatabaseRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List purgeDatabaseRequestDescriptor = + $convert.base64Decode('ChRQdXJnZURhdGFiYXNlUmVxdWVzdA=='); + +@$core.Deprecated('Use purgeDatabaseResponseDescriptor instead') +const PurgeDatabaseResponse$json = { + '1': 'PurgeDatabaseResponse', +}; + +/// Descriptor for `PurgeDatabaseResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List purgeDatabaseResponseDescriptor = + $convert.base64Decode('ChVQdXJnZURhdGFiYXNlUmVzcG9uc2U='); diff --git a/client/lib/generated/api/stats.pb.dart b/client/lib/generated/api/stats.pb.dart new file mode 100644 index 0000000..5556efa --- /dev/null +++ b/client/lib/generated/api/stats.pb.dart @@ -0,0 +1,305 @@ +// This is a generated file - do not edit. +// +// Generated from api/stats.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +import '../db/db.pb.dart' as $1; + +export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; + +class HoursBucket extends $pb.GeneratedMessage { + factory HoursBucket({ + $core.double? regularSecs, + $core.double? overtimeSecs, + }) { + final result = create(); + if (regularSecs != null) result.regularSecs = regularSecs; + if (overtimeSecs != null) result.overtimeSecs = overtimeSecs; + return result; + } + + HoursBucket._(); + + factory HoursBucket.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory HoursBucket.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'HoursBucket', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aD(1, _omitFieldNames ? '' : 'regularSecs') + ..aD(2, _omitFieldNames ? '' : 'overtimeSecs') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + HoursBucket clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + HoursBucket copyWith(void Function(HoursBucket) updates) => + super.copyWith((message) => updates(message as HoursBucket)) + as HoursBucket; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static HoursBucket create() => HoursBucket._(); + @$core.override + HoursBucket createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static HoursBucket getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static HoursBucket? _defaultInstance; + + @$pb.TagNumber(1) + $core.double get regularSecs => $_getN(0); + @$pb.TagNumber(1) + set regularSecs($core.double value) => $_setDouble(0, value); + @$pb.TagNumber(1) + $core.bool hasRegularSecs() => $_has(0); + @$pb.TagNumber(1) + void clearRegularSecs() => $_clearField(1); + + @$pb.TagNumber(2) + $core.double get overtimeSecs => $_getN(1); + @$pb.TagNumber(2) + set overtimeSecs($core.double value) => $_setDouble(1, value); + @$pb.TagNumber(2) + $core.bool hasOvertimeSecs() => $_has(1); + @$pb.TagNumber(2) + void clearOvertimeSecs() => $_clearField(2); +} + +class LeaderboardEntry extends $pb.GeneratedMessage { + factory LeaderboardEntry({ + $core.String? teamMemberId, + $1.TeamMember? teamMember, + HoursBucket? activeSession, + HoursBucket? thisWeek, + HoursBucket? allTime, + $core.double? totalSecs, + }) { + final result = create(); + if (teamMemberId != null) result.teamMemberId = teamMemberId; + if (teamMember != null) result.teamMember = teamMember; + if (activeSession != null) result.activeSession = activeSession; + if (thisWeek != null) result.thisWeek = thisWeek; + if (allTime != null) result.allTime = allTime; + if (totalSecs != null) result.totalSecs = totalSecs; + return result; + } + + LeaderboardEntry._(); + + factory LeaderboardEntry.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory LeaderboardEntry.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'LeaderboardEntry', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'teamMemberId') + ..aOM<$1.TeamMember>(2, _omitFieldNames ? '' : 'teamMember', + subBuilder: $1.TeamMember.create) + ..aOM(3, _omitFieldNames ? '' : 'activeSession', + subBuilder: HoursBucket.create) + ..aOM(4, _omitFieldNames ? '' : 'thisWeek', + subBuilder: HoursBucket.create) + ..aOM(5, _omitFieldNames ? '' : 'allTime', + subBuilder: HoursBucket.create) + ..aD(6, _omitFieldNames ? '' : 'totalSecs') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + LeaderboardEntry clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + LeaderboardEntry copyWith(void Function(LeaderboardEntry) updates) => + super.copyWith((message) => updates(message as LeaderboardEntry)) + as LeaderboardEntry; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LeaderboardEntry create() => LeaderboardEntry._(); + @$core.override + LeaderboardEntry createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static LeaderboardEntry getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static LeaderboardEntry? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get teamMemberId => $_getSZ(0); + @$pb.TagNumber(1) + set teamMemberId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasTeamMemberId() => $_has(0); + @$pb.TagNumber(1) + void clearTeamMemberId() => $_clearField(1); + + @$pb.TagNumber(2) + $1.TeamMember get teamMember => $_getN(1); + @$pb.TagNumber(2) + set teamMember($1.TeamMember value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasTeamMember() => $_has(1); + @$pb.TagNumber(2) + void clearTeamMember() => $_clearField(2); + @$pb.TagNumber(2) + $1.TeamMember ensureTeamMember() => $_ensure(1); + + @$pb.TagNumber(3) + HoursBucket get activeSession => $_getN(2); + @$pb.TagNumber(3) + set activeSession(HoursBucket value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasActiveSession() => $_has(2); + @$pb.TagNumber(3) + void clearActiveSession() => $_clearField(3); + @$pb.TagNumber(3) + HoursBucket ensureActiveSession() => $_ensure(2); + + @$pb.TagNumber(4) + HoursBucket get thisWeek => $_getN(3); + @$pb.TagNumber(4) + set thisWeek(HoursBucket value) => $_setField(4, value); + @$pb.TagNumber(4) + $core.bool hasThisWeek() => $_has(3); + @$pb.TagNumber(4) + void clearThisWeek() => $_clearField(4); + @$pb.TagNumber(4) + HoursBucket ensureThisWeek() => $_ensure(3); + + @$pb.TagNumber(5) + HoursBucket get allTime => $_getN(4); + @$pb.TagNumber(5) + set allTime(HoursBucket value) => $_setField(5, value); + @$pb.TagNumber(5) + $core.bool hasAllTime() => $_has(4); + @$pb.TagNumber(5) + void clearAllTime() => $_clearField(5); + @$pb.TagNumber(5) + HoursBucket ensureAllTime() => $_ensure(4); + + @$pb.TagNumber(6) + $core.double get totalSecs => $_getN(5); + @$pb.TagNumber(6) + set totalSecs($core.double value) => $_setDouble(5, value); + @$pb.TagNumber(6) + $core.bool hasTotalSecs() => $_has(5); + @$pb.TagNumber(6) + void clearTotalSecs() => $_clearField(6); +} + +class GetLeaderboardRequest extends $pb.GeneratedMessage { + factory GetLeaderboardRequest() => create(); + + GetLeaderboardRequest._(); + + factory GetLeaderboardRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory GetLeaderboardRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'GetLeaderboardRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetLeaderboardRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetLeaderboardRequest copyWith( + void Function(GetLeaderboardRequest) updates) => + super.copyWith((message) => updates(message as GetLeaderboardRequest)) + as GetLeaderboardRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetLeaderboardRequest create() => GetLeaderboardRequest._(); + @$core.override + GetLeaderboardRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static GetLeaderboardRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static GetLeaderboardRequest? _defaultInstance; +} + +class GetLeaderboardResponse extends $pb.GeneratedMessage { + factory GetLeaderboardResponse({ + $core.Iterable? entries, + }) { + final result = create(); + if (entries != null) result.entries.addAll(entries); + return result; + } + + GetLeaderboardResponse._(); + + factory GetLeaderboardResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory GetLeaderboardResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'GetLeaderboardResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'tk.api'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'entries', + subBuilder: LeaderboardEntry.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetLeaderboardResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetLeaderboardResponse copyWith( + void Function(GetLeaderboardResponse) updates) => + super.copyWith((message) => updates(message as GetLeaderboardResponse)) + as GetLeaderboardResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetLeaderboardResponse create() => GetLeaderboardResponse._(); + @$core.override + GetLeaderboardResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static GetLeaderboardResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static GetLeaderboardResponse? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get entries => $_getList(0); +} + +const $core.bool _omitFieldNames = + $core.bool.fromEnvironment('protobuf.omit_field_names'); +const $core.bool _omitMessageNames = + $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/client/lib/generated/api/stats.pbenum.dart b/client/lib/generated/api/stats.pbenum.dart new file mode 100644 index 0000000..205e813 --- /dev/null +++ b/client/lib/generated/api/stats.pbenum.dart @@ -0,0 +1,11 @@ +// This is a generated file - do not edit. +// +// Generated from api/stats.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports diff --git a/client/lib/generated/api/stats.pbgrpc.dart b/client/lib/generated/api/stats.pbgrpc.dart new file mode 100644 index 0000000..93f279d --- /dev/null +++ b/client/lib/generated/api/stats.pbgrpc.dart @@ -0,0 +1,75 @@ +// This is a generated file - do not edit. +// +// Generated from api/stats.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports + +import 'dart:async' as $async; +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; + +export 'stats.pb.dart'; + +@$pb.GrpcServiceName('tk.api.StatsService') +class StatsServiceClient extends $grpc.Client { + /// The hostname for this service. + static const $core.String defaultHost = ''; + + /// OAuth scopes needed for the client. + static const $core.List<$core.String> oauthScopes = [ + '', + ]; + + StatsServiceClient(super.channel, {super.options, super.interceptors}); + + $grpc.ResponseFuture<$0.GetLeaderboardResponse> getLeaderboard( + $0.GetLeaderboardRequest request, { + $grpc.CallOptions? options, + }) { + return $createUnaryCall(_$getLeaderboard, request, options: options); + } + + // method descriptors + + static final _$getLeaderboard = + $grpc.ClientMethod<$0.GetLeaderboardRequest, $0.GetLeaderboardResponse>( + '/tk.api.StatsService/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'; + + StatsServiceBase() { + $addMethod($grpc.ServiceMethod<$0.GetLeaderboardRequest, + $0.GetLeaderboardResponse>( + 'GetLeaderboard', + getLeaderboard_Pre, + false, + false, + ($core.List<$core.int> value) => + $0.GetLeaderboardRequest.fromBuffer(value), + ($0.GetLeaderboardResponse value) => value.writeToBuffer())); + } + + $async.Future<$0.GetLeaderboardResponse> getLeaderboard_Pre( + $grpc.ServiceCall $call, + $async.Future<$0.GetLeaderboardRequest> $request) async { + return getLeaderboard($call, await $request); + } + + $async.Future<$0.GetLeaderboardResponse> getLeaderboard( + $grpc.ServiceCall call, $0.GetLeaderboardRequest request); +} diff --git a/client/lib/generated/api/stats.pbjson.dart b/client/lib/generated/api/stats.pbjson.dart new file mode 100644 index 0000000..d307b3a --- /dev/null +++ b/client/lib/generated/api/stats.pbjson.dart @@ -0,0 +1,110 @@ +// This is a generated file - do not edit. +// +// Generated from api/stats.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_relative_imports +// ignore_for_file: unused_import + +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; + +@$core.Deprecated('Use hoursBucketDescriptor instead') +const HoursBucket$json = { + '1': 'HoursBucket', + '2': [ + {'1': 'regular_secs', '3': 1, '4': 1, '5': 1, '10': 'regularSecs'}, + {'1': 'overtime_secs', '3': 2, '4': 1, '5': 1, '10': 'overtimeSecs'}, + ], +}; + +/// Descriptor for `HoursBucket`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List hoursBucketDescriptor = $convert.base64Decode( + 'CgtIb3Vyc0J1Y2tldBIhCgxyZWd1bGFyX3NlY3MYASABKAFSC3JlZ3VsYXJTZWNzEiMKDW92ZX' + 'J0aW1lX3NlY3MYAiABKAFSDG92ZXJ0aW1lU2Vjcw=='); + +@$core.Deprecated('Use leaderboardEntryDescriptor instead') +const LeaderboardEntry$json = { + '1': 'LeaderboardEntry', + '2': [ + {'1': 'team_member_id', '3': 1, '4': 1, '5': 9, '10': 'teamMemberId'}, + { + '1': 'team_member', + '3': 2, + '4': 1, + '5': 11, + '6': '.tk.db.TeamMember', + '10': 'teamMember' + }, + { + '1': 'active_session', + '3': 3, + '4': 1, + '5': 11, + '6': '.tk.api.HoursBucket', + '10': 'activeSession' + }, + { + '1': 'this_week', + '3': 4, + '4': 1, + '5': 11, + '6': '.tk.api.HoursBucket', + '10': 'thisWeek' + }, + { + '1': 'all_time', + '3': 5, + '4': 1, + '5': 11, + '6': '.tk.api.HoursBucket', + '10': 'allTime' + }, + {'1': 'total_secs', '3': 6, '4': 1, '5': 1, '10': 'totalSecs'}, + ], +}; + +/// Descriptor for `LeaderboardEntry`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List leaderboardEntryDescriptor = $convert.base64Decode( + 'ChBMZWFkZXJib2FyZEVudHJ5EiQKDnRlYW1fbWVtYmVyX2lkGAEgASgJUgx0ZWFtTWVtYmVySW' + 'QSMgoLdGVhbV9tZW1iZXIYAiABKAsyES50ay5kYi5UZWFtTWVtYmVyUgp0ZWFtTWVtYmVyEjoK' + 'DmFjdGl2ZV9zZXNzaW9uGAMgASgLMhMudGsuYXBpLkhvdXJzQnVja2V0Ug1hY3RpdmVTZXNzaW' + '9uEjAKCXRoaXNfd2VlaxgEIAEoCzITLnRrLmFwaS5Ib3Vyc0J1Y2tldFIIdGhpc1dlZWsSLgoI' + 'YWxsX3RpbWUYBSABKAsyEy50ay5hcGkuSG91cnNCdWNrZXRSB2FsbFRpbWUSHQoKdG90YWxfc2' + 'VjcxgGIAEoAVIJdG90YWxTZWNz'); + +@$core.Deprecated('Use getLeaderboardRequestDescriptor instead') +const GetLeaderboardRequest$json = { + '1': 'GetLeaderboardRequest', +}; + +/// Descriptor for `GetLeaderboardRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getLeaderboardRequestDescriptor = + $convert.base64Decode('ChVHZXRMZWFkZXJib2FyZFJlcXVlc3Q='); + +@$core.Deprecated('Use getLeaderboardResponseDescriptor instead') +const GetLeaderboardResponse$json = { + '1': 'GetLeaderboardResponse', + '2': [ + { + '1': 'entries', + '3': 1, + '4': 3, + '5': 11, + '6': '.tk.api.LeaderboardEntry', + '10': 'entries' + }, + ], +}; + +/// Descriptor for `GetLeaderboardResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getLeaderboardResponseDescriptor = + $convert.base64Decode( + 'ChZHZXRMZWFkZXJib2FyZFJlc3BvbnNlEjIKB2VudHJpZXMYASADKAsyGC50ay5hcGkuTGVhZG' + 'VyYm9hcmRFbnRyeVIHZW50cmllcw=='); diff --git a/client/lib/helpers/collection_storage.dart b/client/lib/helpers/collection_storage.dart index d491b7a..08b542c 100644 --- a/client/lib/helpers/collection_storage.dart +++ b/client/lib/helpers/collection_storage.dart @@ -102,28 +102,36 @@ class CollectionStorage { return ids.contains(id); } - /// Process stream updates and return a map of changes. + /// Process stream updates, handling both upserts and deletes. /// - /// Takes an iterable of response items and a callback: - /// - extractIdAndItem: Given a response item, returns (id, item) if valid, or null to skip + /// Takes an iterable of response items and callbacks: + /// - hasItem: Returns true if the response item contains data (false = delete) + /// - getId: Extracts the ID from a response item + /// - getItem: Extracts the data from a response item /// - /// Returns a map of all items that were updated. - Map processStreamUpdates( - Iterable responseItems, - (String, T)? Function(R) extractIdAndItem, - ) { + /// Returns a record of (updates, deletedIds). + ({Map updates, Set deletedIds}) processStreamUpdates( + Iterable responseItems, { + required bool Function(R) hasItem, + required String Function(R) getId, + required T Function(R) getItem, + }) { final updates = {}; + final deletedIds = {}; for (final responseItem in responseItems) { - final result = extractIdAndItem(responseItem); - if (result != null) { - final (id, item) = result; + final id = getId(responseItem); + if (hasItem(responseItem)) { + final item = getItem(responseItem); set(id, item); updates[id] = item; + } else { + remove(id); + deletedIds.add(id); } } - return updates; + return (updates: updates, deletedIds: deletedIds); } /// Replaces the entire collection with the given items. @@ -131,16 +139,16 @@ class CollectionStorage { /// Clears stale entries that are not in [items] and saves the new data. /// Returns the new full map. Map replaceAll( - Iterable responseItems, - (String, T)? Function(R) extractIdAndItem, - ) { + Iterable responseItems, { + required bool Function(R) hasItem, + required String Function(R) getId, + required T Function(R) getItem, + }) { final incoming = {}; for (final responseItem in responseItems) { - final result = extractIdAndItem(responseItem); - if (result != null) { - final (id, item) = result; - incoming[id] = item; + if (hasItem(responseItem)) { + incoming[getId(responseItem)] = getItem(responseItem); } } @@ -160,23 +168,13 @@ class CollectionStorage { return incoming; } - /// Sets up a listener for stream updates that automatically syncs with storage and state. + /// Sets up a listener for stream updates that automatically syncs storage and provider state. /// /// Uses the server-provided [SyncType] to determine behavior: /// - [SyncType.FULL]: Replace the entire local collection with the server snapshot. - /// - [SyncType.PARTIAL]: Merge incremental updates into the existing collection. + /// - [SyncType.PARTIAL]: Merge incremental updates and remove deleted items. /// - /// Parameters: - /// - ref: The Riverpod ref - /// - streamProvider: The stream provider to listen to - /// - extractItems: Function to extract the list of response items from the stream response - /// - getSyncType: Function to extract the SyncType from the stream response - /// - hasItem: Function to check if a response item has the actual data (e.g., hasGameMatch()) - /// - getId: Function to extract the ID from a response item - /// - getItem: Function to extract the actual item from a response item - /// - onSync: Callback when a full sync replaces local state - /// - onUpdate: Callback when incremental updates are merged - /// - onError: Optional callback when an error occurs + /// State management is handled internally — callers just provide [getState] and [setState]. void bindToStream({ required Ref ref, required ProviderListenable> streamProvider, @@ -185,28 +183,35 @@ class CollectionStorage { required bool Function(ResponseItem) hasItem, required String Function(ResponseItem) getId, required T Function(ResponseItem) getItem, - void Function(Map fullState)? onSync, - void Function(Map updates)? onUpdate, + required Map Function() getState, + required void Function(Map) setState, void Function(Object error, StackTrace stackTrace)? onError, }) { ref.listen(streamProvider, (previous, next) { next.when( data: (response) { final items = extractItems(response); - (String, T)? extractor(ResponseItem responseItem) => - hasItem(responseItem) - ? (getId(responseItem), getItem(responseItem)) - : null; - final syncType = getSyncType(response); if (syncType == SyncType.FULL) { - final fullState = replaceAll(items, extractor); - onSync?.call(fullState); + final fullState = replaceAll( + items, + hasItem: hasItem, + getId: getId, + getItem: getItem, + ); + setState(fullState); } else { - final updates = processStreamUpdates(items, extractor); - if (updates.isNotEmpty) { - onUpdate?.call(updates); + final (:updates, :deletedIds) = processStreamUpdates( + items, + hasItem: hasItem, + getId: getId, + getItem: getItem, + ); + if (updates.isNotEmpty || deletedIds.isNotEmpty) { + final newState = {...getState(), ...updates}; + newState.removeWhere((id, _) => deletedIds.contains(id)); + setState(newState); } } }, diff --git a/client/lib/helpers/session_helper.dart b/client/lib/helpers/session_helper.dart new file mode 100644 index 0000000..dbcd70e --- /dev/null +++ b/client/lib/helpers/session_helper.dart @@ -0,0 +1,14 @@ +import 'package:time_keeper/generated/db/db.pb.dart'; + +bool isMemberCheckedIn(String memberId, Iterable sessions) { + for (final session in sessions) { + for (final ms in session.memberSessions) { + if (ms.teamMemberId == memberId && + ms.hasCheckInTime() && + !ms.hasCheckOutTime()) { + return true; + } + } + } + return false; +} diff --git a/client/lib/models/session_status.dart b/client/lib/models/session_status.dart new file mode 100644 index 0000000..a43529b --- /dev/null +++ b/client/lib/models/session_status.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/utils/time.dart'; + +enum SessionStatus { current, overtime, upcoming, finished } + +SessionStatus getSessionStatus(Session session) { + if (session.finished) return SessionStatus.finished; + + final now = DateTime.now(); + final start = session.startTime.toDateTime(); + final end = session.endTime.toDateTime(); + + if (now.isBefore(start)) return SessionStatus.upcoming; + if (now.isAfter(end)) return SessionStatus.overtime; + return SessionStatus.current; +} + +Color statusColor(SessionStatus status) { + switch (status) { + case SessionStatus.current: + return Colors.green; + case SessionStatus.overtime: + return Colors.red; + case SessionStatus.upcoming: + return Colors.blue; + case SessionStatus.finished: + return Colors.grey; + } +} + +String statusLabel(SessionStatus status) { + switch (status) { + case SessionStatus.current: + return 'Current'; + case SessionStatus.overtime: + return 'OVERTIME'; + case SessionStatus.upcoming: + return 'Upcoming'; + case SessionStatus.finished: + return 'Finished'; + } +} + +/// Sort: current/overtime first, then upcoming (soonest first), then finished (newest first). +int compareSessionEntries( + MapEntry a, + MapEntry b, +) { + final aStatus = getSessionStatus(a.value); + final bStatus = getSessionStatus(b.value); + + const order = { + SessionStatus.current: 0, + SessionStatus.overtime: 0, + SessionStatus.upcoming: 1, + SessionStatus.finished: 2, + }; + + final statusCmp = order[aStatus]!.compareTo(order[bStatus]!); + if (statusCmp != 0) return statusCmp; + + final aTime = a.value.startTime.toDateTime(); + final bTime = b.value.startTime.toDateTime(); + + // Upcoming: soonest first. Current/Overtime/Finished: newest first. + if (aStatus == SessionStatus.upcoming) { + return aTime.compareTo(bTime); + } + return bTime.compareTo(aTime); +} diff --git a/client/lib/models/stats_data.dart b/client/lib/models/stats_data.dart new file mode 100644 index 0000000..8e299eb --- /dev/null +++ b/client/lib/models/stats_data.dart @@ -0,0 +1,106 @@ +import 'package:time_keeper/generated/db/db.pb.dart'; + +/// Time range filter for the stats dashboard. +enum StatsRange { day, week, month, all } + +String statsRangeLabel(StatsRange range) { + switch (range) { + case StatsRange.day: + return 'Today'; + case StatsRange.week: + return 'This Week'; + case StatsRange.month: + return 'This Month'; + case StatsRange.all: + return 'All Time'; + } +} + +class MemberHoursData { + final String memberId; + final String name; + final TeamMemberType memberType; + double regularSecs; + double overtimeSecs; + + MemberHoursData({ + required this.memberId, + required this.name, + required this.memberType, + this.regularSecs = 0, + this.overtimeSecs = 0, + }); + + double get totalSecs => regularSecs + overtimeSecs; + double get overtimePercent => + totalSecs > 0 ? (overtimeSecs / totalSecs) * 100 : 0; +} + +class DayHoursData { + final DateTime date; + double regularSecs; + double overtimeSecs; + + DayHoursData({ + required this.date, + this.regularSecs = 0, + this.overtimeSecs = 0, + }); +} + +class DayAttendanceData { + final DateTime date; + int uniqueMembers; + + DayAttendanceData({required this.date, this.uniqueMembers = 0}); +} + +class DayMemberDetail { + final String memberId; + final String name; + final TeamMemberType memberType; + final double regularSecs; + final double overtimeSecs; + + DayMemberDetail({ + required this.memberId, + required this.name, + required this.memberType, + required this.regularSecs, + required this.overtimeSecs, + }); + + double get totalSecs => regularSecs + overtimeSecs; +} + +class LocationAttendanceData { + final String locationId; + final String locationName; + int checkInCount; + + LocationAttendanceData({ + required this.locationId, + required this.locationName, + this.checkInCount = 0, + }); +} + +class AttendanceInsights { + final String avgCheckInTime; + final String avgCheckOutTime; + final String avgVisitDuration; + final String mostActiveLocation; + final String busiestDay; + final int uniqueMembers; + final double avgAttendancePerSession; + + AttendanceInsights({ + required this.avgCheckInTime, + required this.avgCheckOutTime, + required this.avgVisitDuration, + required this.mostActiveLocation, + required this.busiestDay, + required this.uniqueMembers, + required this.avgAttendancePerSession, + }); +} diff --git a/client/lib/providers/location_provider.dart b/client/lib/providers/location_provider.dart index b647849..396afcf 100644 --- a/client/lib/providers/location_provider.dart +++ b/client/lib/providers/location_provider.dart @@ -53,8 +53,8 @@ class Locations extends _$Locations { hasItem: (item) => item.hasLocation(), getId: (item) => item.id, getItem: (item) => item.location, - onSync: (fullState) => state = fullState, - onUpdate: (updates) => state = {...state, ...updates}, + getState: () => state, + setState: (newState) => state = newState, ); return localLocations; diff --git a/client/lib/providers/location_provider.g.dart b/client/lib/providers/location_provider.g.dart index 68d322d..d33331c 100644 --- a/client/lib/providers/location_provider.g.dart +++ b/client/lib/providers/location_provider.g.dart @@ -129,7 +129,7 @@ final class LocationsProvider } } -String _$locationsHash() => r'66ac65cc17afe87eb4b4cac70a90904f89db9364'; +String _$locationsHash() => r'f5dfe50908a959d5ac8ca292276fb7407ac0ce70'; abstract class _$Locations extends $Notifier> { Map build(); diff --git a/client/lib/providers/session_provider.dart b/client/lib/providers/session_provider.dart index 518e903..6be5f75 100644 --- a/client/lib/providers/session_provider.dart +++ b/client/lib/providers/session_provider.dart @@ -52,8 +52,8 @@ class Sessions extends _$Sessions { hasItem: (item) => item.hasSession(), getId: (item) => item.id, getItem: (item) => item.session, - onSync: (fullState) => state = fullState, - onUpdate: (updates) => state = {...state, ...updates}, + getState: () => state, + setState: (newState) => state = newState, ); return localSessions; diff --git a/client/lib/providers/session_provider.g.dart b/client/lib/providers/session_provider.g.dart index a63fcb8..1bb9d6c 100644 --- a/client/lib/providers/session_provider.g.dart +++ b/client/lib/providers/session_provider.g.dart @@ -129,7 +129,7 @@ final class SessionsProvider } } -String _$sessionsHash() => r'555df6c37b6ec5b96a8014b40ef71e314d2fd0d1'; +String _$sessionsHash() => r'016b85181a79b84edf3fe259a9e3f4aa3a929bff'; abstract class _$Sessions extends $Notifier> { Map build(); diff --git a/client/lib/providers/stats_provider.dart b/client/lib/providers/stats_provider.dart new file mode 100644 index 0000000..9c22095 --- /dev/null +++ b/client/lib/providers/stats_provider.dart @@ -0,0 +1,22 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:time_keeper/generated/api/stats.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'; + +@Riverpod(keepAlive: true) +StatsServiceClient statsService(Ref ref) { + final channel = ref.watch(grpcChannelProvider); + final token = ref.watch(tokenProvider); + final options = authCallOptions(token); + + return StatsServiceClient(channel, options: options); +} + +@riverpod +Future leaderboard(Ref ref) async { + final client = ref.watch(statsServiceProvider); + return client.getLeaderboard(GetLeaderboardRequest()); +} diff --git a/client/lib/providers/stats_provider.g.dart b/client/lib/providers/stats_provider.g.dart new file mode 100644 index 0000000..87bfa2b --- /dev/null +++ b/client/lib/providers/stats_provider.g.dart @@ -0,0 +1,98 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(statsService) +final statsServiceProvider = StatsServiceProvider._(); + +final class StatsServiceProvider + extends + $FunctionalProvider< + StatsServiceClient, + StatsServiceClient, + StatsServiceClient + > + with $Provider { + StatsServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'statsServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$statsServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + StatsServiceClient create(Ref ref) { + return statsService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(StatsServiceClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$statsServiceHash() => r'c44fcaa78d2b24b95e3f055bfb4a7906cf94f7ec'; + +@ProviderFor(leaderboard) +final leaderboardProvider = LeaderboardProvider._(); + +final class LeaderboardProvider + extends + $FunctionalProvider< + AsyncValue, + GetLeaderboardResponse, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + LeaderboardProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'leaderboardProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$leaderboardHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return leaderboard(ref); + } +} + +String _$leaderboardHash() => r'98e7c6033bc275fb745a458f3b2fba617455c888'; diff --git a/client/lib/providers/team_member_provider.dart b/client/lib/providers/team_member_provider.dart index 87bac2f..ece129e 100644 --- a/client/lib/providers/team_member_provider.dart +++ b/client/lib/providers/team_member_provider.dart @@ -52,8 +52,8 @@ class TeamMembers extends _$TeamMembers { hasItem: (item) => item.hasTeamMember(), getId: (item) => item.id, getItem: (item) => item.teamMember, - onSync: (fullState) => state = fullState, - onUpdate: (updates) => state = {...state, ...updates}, + getState: () => state, + setState: (newState) => state = newState, ); return localMembers; diff --git a/client/lib/providers/team_member_provider.g.dart b/client/lib/providers/team_member_provider.g.dart index ab24996..18fe2e8 100644 --- a/client/lib/providers/team_member_provider.g.dart +++ b/client/lib/providers/team_member_provider.g.dart @@ -129,7 +129,7 @@ final class TeamMembersProvider } } -String _$teamMembersHash() => r'1da976a7ba53d0dbdce124ceccdecbee079308e4'; +String _$teamMembersHash() => r'70ad01e4e81dce9053f8fb2480eace8dceddaed1'; abstract class _$TeamMembers extends $Notifier> { Map build(); diff --git a/client/lib/providers/user_provider.dart b/client/lib/providers/user_provider.dart index 5d6ef0a..1091536 100644 --- a/client/lib/providers/user_provider.dart +++ b/client/lib/providers/user_provider.dart @@ -35,11 +35,11 @@ class Users extends _$Users { streamProvider: usersStreamProvider, extractItems: (response) => response.users, getSyncType: (response) => response.syncType, - hasItem: (item) => item.hasId(), + hasItem: (item) => item.username.isNotEmpty, getId: (item) => item.id, getItem: (item) => item, - onSync: (fullState) => state = fullState, - onUpdate: (updates) => state = {...state, ...updates}, + getState: () => state, + setState: (newState) => state = newState, ); return localUsers; diff --git a/client/lib/providers/user_provider.g.dart b/client/lib/providers/user_provider.g.dart index e9bfc78..4f40a75 100644 --- a/client/lib/providers/user_provider.g.dart +++ b/client/lib/providers/user_provider.g.dart @@ -82,7 +82,7 @@ final class UsersProvider } } -String _$usersHash() => r'cc987a48d976e7e8dc98bf281dbd3026c1ea70ec'; +String _$usersHash() => r'604435de91697b846079842eec1bd2887face6a7'; abstract class _$Users extends $Notifier> { Map build(); diff --git a/client/lib/router/app_routes.dart b/client/lib/router/app_routes.dart index b72cc4b..354f95b 100644 --- a/client/lib/router/app_routes.dart +++ b/client/lib/router/app_routes.dart @@ -16,6 +16,8 @@ import 'package:go_router/go_router.dart'; enum AppRoute { // Public routes kiosk(path: '/', name: 'kiosk', railIndex: null), + leaderboard(path: '/leaderboard', name: 'leaderboard', railIndex: null), + calendar(path: '/calendar', name: 'calendar', railIndex: null), login(path: '/login', name: 'login', railIndex: null), settings(path: '/settings', name: 'settings', railIndex: null), @@ -27,7 +29,10 @@ enum AppRoute { ), users(path: '/users', name: 'users', railIndex: 1), - team(path: '/team', name: 'team', railIndex: 2); + 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); const AppRoute({ required this.path, diff --git a/client/lib/router/router.dart b/client/lib/router/router.dart index 1768925..0788714 100644 --- a/client/lib/router/router.dart +++ b/client/lib/router/router.dart @@ -15,8 +15,17 @@ import 'package:time_keeper/views/login/login_view.dart' deferred as login; import 'package:time_keeper/views/setup/setup_view.dart' deferred as setup; import 'package:time_keeper/views/settings/settings_view.dart' deferred as settings; +import 'package:time_keeper/views/sessions/session_view.dart' + deferred as sessions; import 'package:time_keeper/views/team/team_view.dart' deferred as team; +import 'package:time_keeper/views/locations/locations_view.dart' + deferred as locations; 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/calendar/calendar_view.dart' + deferred as calendar; part 'router.g.dart'; @@ -106,6 +115,30 @@ 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, + pageBuilder: (context, state) => _buildTransitionPage( + key: state.pageKey, + child: DeferredWidget( + libraryKey: AppRoute.calendar.path, + libraryLoader: calendar.loadLibrary, + builder: (context) => calendar.CalendarView(), + ), + ), + ), GoRoute( name: AppRoute.kiosk.name, path: AppRoute.kiosk.path, @@ -165,6 +198,42 @@ GoRouter router(Ref ref) { ), ), ), + 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.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.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(), + ), + ), + ), ], ), ], diff --git a/client/lib/router/router.g.dart b/client/lib/router/router.g.dart index 3b53a0e..05e3752 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'fb33d7017c810869d00978e8027cf0fe22cf29d7'; +String _$routerHash() => r'a9d4f60a573f9b061c1dc2c0c04e20a20b7ef5dc'; diff --git a/client/lib/utils/formatting.dart b/client/lib/utils/formatting.dart new file mode 100644 index 0000000..5638b14 --- /dev/null +++ b/client/lib/utils/formatting.dart @@ -0,0 +1,65 @@ +const weekdayAbbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const weekdayFull = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +]; +const monthAbbr = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/// Formats a DateTime as "Wed, Jan 5". +String formatDate(DateTime dt) { + final weekday = weekdayAbbr[dt.weekday - 1]; + final month = monthAbbr[dt.month - 1]; + return '$weekday, $month ${dt.day}'; +} + +/// Formats a DateTime as "3:05 PM". +String formatTime(DateTime dt) { + final hour = dt.hour % 12 == 0 ? 12 : dt.hour % 12; + final minute = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$hour:$minute $period'; +} + +/// Formats a Duration as "2h 30m". +String formatDuration(Duration d) { + final hours = d.inHours; + final minutes = d.inMinutes.remainder(60); + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; +} + +/// Formats hour and minute as "3:05 PM". +String formatTimeOfDay(int hour, int minute) { + final h = hour % 12 == 0 ? 12 : hour % 12; + final m = minute.toString().padLeft(2, '0'); + final period = hour < 12 ? 'AM' : 'PM'; + return '$h:$m $period'; +} + +/// Formats seconds as "2h 30m". +String formatSecsAsHoursMinutes(double secs) { + final totalMinutes = (secs / 60).round(); + final hours = totalMinutes ~/ 60; + final minutes = totalMinutes % 60; + if (hours > 0) return '${hours}h ${minutes}m'; + if (minutes > 0) return '${minutes}m'; + return '0m'; +} diff --git a/client/lib/views/calendar/calendar_table.dart b/client/lib/views/calendar/calendar_table.dart new file mode 100644 index 0000000..beadb25 --- /dev/null +++ b/client/lib/views/calendar/calendar_table.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +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'; + +class CalendarTable extends ConsumerWidget { + final List> sessions; + + const CalendarTable({super.key, required this.sessions}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locations = ref.watch(locationsProvider); + final teamMembers = ref.watch(teamMembersProvider); + final theme = Theme.of(context); + + return BaseTable( + alternatingRows: true, + headerDecoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + ), + headers: [ + BaseTableCell( + child: Text('Date', style: TextStyle(color: Colors.white)), + flex: 2, + ), + BaseTableCell( + child: Text('Time', style: TextStyle(color: Colors.white)), + flex: 2, + ), + BaseTableCell( + child: Text('Duration', style: TextStyle(color: Colors.white)), + ), + 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( + cells: [ + BaseTableCell(child: Text(formatDate(start)), flex: 2), + BaseTableCell( + child: Text('${formatTime(start)} - ${formatTime(end)}'), + flex: 2, + ), + 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/calendar/calendar_view.dart b/client/lib/views/calendar/calendar_view.dart new file mode 100644 index 0000000..75ecce5 --- /dev/null +++ b/client/lib/views/calendar/calendar_view.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/models/session_status.dart'; +import 'package:time_keeper/providers/session_provider.dart'; +import 'package:time_keeper/utils/formatting.dart'; +import 'package:time_keeper/utils/time.dart'; +import 'package:time_keeper/views/calendar/calendar_table.dart'; +import 'package:time_keeper/views/sessions/session_calendar.dart'; + +class CalendarView extends HookConsumerWidget { + const CalendarView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessions = ref.watch(sessionsProvider); + final theme = Theme.of(context); + + final showCalendar = useState(true); + final selectedDate = useState(null); + + final sorted = sessions.entries.toList()..sort(compareSessionEntries); + + final filtered = selectedDate.value != null + ? sorted.where((entry) { + final dt = entry.value.startTime.toDateTime(); + final sel = selectedDate.value!; + return dt.year == sel.year && + dt.month == sel.month && + dt.day == sel.day; + }).toList() + : sorted; + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.calendar_month, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('Calendar', style: theme.textTheme.headlineMedium), + const Spacer(), + SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + icon: Icon(Icons.calendar_month), + label: Text('Calendar'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.table_rows), + label: Text('Table'), + ), + ], + selected: {showCalendar.value}, + onSelectionChanged: (value) { + showCalendar.value = value.first; + if (!value.first) selectedDate.value = null; + }, + ), + ], + ), + const SizedBox(height: 24), + + // Calendar + if (showCalendar.value) ...[ + SessionCalendar( + sessions: sessions, + selectedDate: selectedDate.value, + onDateSelected: (date) { + if (selectedDate.value == date) { + selectedDate.value = null; + } else { + selectedDate.value = date; + } + }, + ), + if (selectedDate.value != null) + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Row( + children: [ + Text( + 'Showing: ${formatDate(selectedDate.value!)}', + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => selectedDate.value = null, + icon: const Icon(Icons.clear, size: 16), + label: const Text('Clear filter'), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Table + Expanded(child: CalendarTable(sessions: filtered)), + ], + ), + ); + } +} diff --git a/client/lib/views/kiosk/kiosk_dialog.dart b/client/lib/views/kiosk/kiosk_dialog.dart index b164321..4aa168c 100644 --- a/client/lib/views/kiosk/kiosk_dialog.dart +++ b/client/lib/views/kiosk/kiosk_dialog.dart @@ -8,6 +8,7 @@ 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/grpc_result.dart'; +import 'package:time_keeper/helpers/session_helper.dart'; import 'package:time_keeper/widgets/dialogs/base_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; @@ -69,19 +70,6 @@ class _KioskDialogContent extends HookConsumerWidget { member.secondaryAlias.toLowerCase().contains(query); }).toList(); - bool isCheckedIn(String memberId) { - for (final session in sessions) { - for (final ms in session.memberSessions) { - if (ms.teamMemberId == memberId && - ms.hasCheckInTime() && - !ms.hasCheckOutTime()) { - return true; - } - } - } - return false; - } - Widget membersList() { if (filteredMembers.isEmpty) { return Center( @@ -102,7 +90,7 @@ class _KioskDialogContent extends HookConsumerWidget { final entry = filteredMembers[index]; final member = entry.value; final memberId = entry.key; - final checkedIn = isCheckedIn(memberId); + final checkedIn = isMemberCheckedIn(memberId, sessions); final alias = member.alias.isNotEmpty ? ' (${member.alias})' : ''; return ListTile( diff --git a/client/lib/views/kiosk/session_info_bar.dart b/client/lib/views/kiosk/session_info_bar.dart index e68101f..ad8e00d 100644 --- a/client/lib/views/kiosk/session_info_bar.dart +++ b/client/lib/views/kiosk/session_info_bar.dart @@ -1,5 +1,6 @@ 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/utils/time.dart'; import 'package:time_keeper/widgets/time_until.dart'; @@ -9,35 +10,6 @@ class SessionInfoBar extends StatelessWidget { const SessionInfoBar({super.key, this.currentSession, this.nextSession}); - static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - static const _months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - - String _formatTime(DateTime dt) { - final hour = dt.hour % 12 == 0 ? 12 : dt.hour % 12; - final minute = dt.minute.toString().padLeft(2, '0'); - final period = dt.hour < 12 ? 'AM' : 'PM'; - return '$hour:$minute $period'; - } - - String _formatDate(DateTime dt) { - final weekday = _weekdays[dt.weekday - 1]; - final month = _months[dt.month - 1]; - return '$weekday, $month ${dt.day}'; - } - Widget _sessionDateTime(Session session, Color color) { final start = session.startTime.toDateTime(); final end = session.endTime.toDateTime(); @@ -45,7 +17,7 @@ class SessionInfoBar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - _formatDate(start), + formatDate(start), style: TextStyle(color: color, fontWeight: FontWeight.w500), ), Padding( @@ -57,7 +29,7 @@ class SessionInfoBar extends StatelessWidget { ), ), Text( - '${_formatTime(start)} - ${_formatTime(end)}', + '${formatTime(start)} - ${formatTime(end)}', style: TextStyle(color: color), ), ], diff --git a/client/lib/views/kiosk/team_member_row.dart b/client/lib/views/kiosk/team_member_row.dart index 9a44dba..af36e23 100644 --- a/client/lib/views/kiosk/team_member_row.dart +++ b/client/lib/views/kiosk/team_member_row.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:time_keeper/generated/common/common.pb.dart'; import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/utils/formatting.dart'; import 'package:time_keeper/utils/time.dart'; class TeamMemberRow extends StatelessWidget { @@ -18,11 +19,7 @@ class TeamMemberRow extends StatelessWidget { @override Widget build(BuildContext context) { final alias = teamMember.alias.isNotEmpty ? '(${teamMember.alias})' : ''; - final dt = timeIn.toDateTime(); - final hour = dt.hour % 12 == 0 ? 12 : dt.hour % 12; - final minute = dt.minute.toString().padLeft(2, '0'); - final period = dt.hour >= 12 ? 'PM' : 'AM'; - final timeStr = '$hour:$minute $period'; + final timeStr = formatTime(timeIn.toDateTime()); return Row( children: [ Expanded(child: Center(child: Text('${teamMember.firstName} $alias'))), diff --git a/client/lib/views/leaderboard/leaderboard_view.dart b/client/lib/views/leaderboard/leaderboard_view.dart new file mode 100644 index 0000000..5e80233 --- /dev/null +++ b/client/lib/views/leaderboard/leaderboard_view.dart @@ -0,0 +1,208 @@ +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/db/db.pb.dart'; +import 'package:time_keeper/providers/stats_provider.dart'; +import 'package:time_keeper/utils/formatting.dart'; + +class LeaderboardView extends ConsumerWidget { + const LeaderboardView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final leaderboard = ref.watch(leaderboardProvider); + + return leaderboard.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Failed to load leaderboard')), + data: (response) => _LeaderboardTable(entries: response.entries), + ); + } +} + +class _LeaderboardTable extends StatelessWidget { + final List entries; + + const _LeaderboardTable({required this.entries}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final evenColor = isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.02); + final oddColor = isDark + ? Colors.white.withValues(alpha: 0.07) + : Colors.black.withValues(alpha: 0.05); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 25), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.leaderboard, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Leaderboard', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 16), + Text( + '${entries.length} members', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + child: const Row( + children: [ + SizedBox(width: 40, child: Center(child: _HeaderText('#'))), + Expanded(flex: 3, child: _HeaderText('Member')), + Expanded(flex: 2, child: _HeaderText('Type')), + Expanded(flex: 2, child: _HeaderText('Session')), + Expanded(flex: 2, child: _HeaderText('Week')), + Expanded(flex: 2, child: _HeaderText('All Time')), + Expanded(flex: 2, child: _HeaderText('Total')), + ], + ), + ), + Expanded( + child: entries.isEmpty + ? Center( + child: Text( + 'No data yet', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + itemCount: entries.length, + itemBuilder: (context, i) { + return Container( + height: 40, + decoration: BoxDecoration( + color: i % 2 == 0 ? evenColor : oddColor, + ), + child: _LeaderboardRow(rank: i + 1, entry: entries[i]), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _HeaderText extends StatelessWidget { + final String text; + const _HeaderText(this.text); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text( + text, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ); + } +} + +class _LeaderboardRow extends StatelessWidget { + final int rank; + final LeaderboardEntry entry; + + const _LeaderboardRow({required this.rank, required this.entry}); + + @override + Widget build(BuildContext context) { + final member = entry.teamMember; + final alias = member.alias.isNotEmpty ? ' (${member.alias})' : ''; + final name = '${member.firstName}$alias'; + final memberType = member.memberType == TeamMemberType.STUDENT + ? 'Student' + : 'Mentor'; + + return Row( + children: [ + SizedBox( + width: 40, + child: Center( + child: Text( + '$rank', + style: TextStyle( + fontWeight: rank <= 3 ? FontWeight.bold : FontWeight.normal, + color: rank <= 3 ? Theme.of(context).colorScheme.primary : null, + ), + ), + ), + ), + Expanded(flex: 3, child: Center(child: Text(name))), + Expanded(flex: 2, child: Center(child: Text(memberType))), + Expanded(flex: 2, child: _HoursCell(bucket: entry.activeSession)), + Expanded(flex: 2, child: _HoursCell(bucket: entry.thisWeek)), + Expanded(flex: 2, child: _HoursCell(bucket: entry.allTime)), + Expanded( + flex: 2, + child: Center( + child: Text( + formatSecsAsHoursMinutes(entry.totalSecs), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ), + ], + ); + } +} + +class _HoursCell extends StatelessWidget { + final HoursBucket bucket; + + const _HoursCell({required this.bucket}); + + @override + Widget build(BuildContext context) { + if (bucket.regularSecs == 0 && bucket.overtimeSecs == 0) { + return const Center(child: Text('-')); + } + + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(formatSecsAsHoursMinutes(bucket.regularSecs)), + if (bucket.overtimeSecs > 0) ...[ + const SizedBox(width: 4), + Text( + '+${formatSecsAsHoursMinutes(bucket.overtimeSecs)}', + style: TextStyle(color: Colors.red.shade300, fontSize: 12), + ), + ], + ], + ), + ); + } +} diff --git a/client/lib/views/locations/location_dialog.dart b/client/lib/views/locations/location_dialog.dart new file mode 100644 index 0000000..56dac23 --- /dev/null +++ b/client/lib/views/locations/location_dialog.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +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/widgets/dialogs/confirm_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; + +void showLocationDialog( + BuildContext context, + WidgetRef ref, { + String? id, + String? existingName, +}) { + final isEdit = id != null; + + PopupDialog.info( + title: isEdit ? 'Edit Location' : 'Add Location', + message: _LocationForm( + ref: ref, + isEdit: isEdit, + locationId: id, + initialName: existingName, + ), + actions: const [], + ).show(context); +} + +void showDeleteLocationDialog( + BuildContext context, + WidgetRef ref, { + required String id, + required String name, +}) { + ConfirmDialog.warn( + title: 'Delete Location', + message: Text('Are you sure you want to delete "$name"?'), + confirmText: 'Delete', + onConfirmAsyncGrpc: () async { + final client = ref.read(locationServiceProvider); + return await callGrpcEndpoint( + () => client.deleteLocation(DeleteLocationRequest(id: id)), + ); + }, + showResultDialog: true, + successMessage: Text('"$name" has been deleted'), + ).show(context); +} + +class _LocationForm extends HookWidget { + final WidgetRef ref; + 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) { + final nameController = useTextEditingController(text: initialName ?? ''); + + return SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Location Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final name = nameController.text.trim(); + if (name.isEmpty) return; + + Navigator.of(context).pop(); + + 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'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/client/lib/views/locations/locations_view.dart b/client/lib/views/locations/locations_view.dart new file mode 100644 index 0000000..ddc5415 --- /dev/null +++ b/client/lib/views/locations/locations_view.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/generated/api/location.pbgrpc.dart'; +import 'package:time_keeper/providers/location_provider.dart'; +import 'package:time_keeper/views/locations/location_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; +import 'package:time_keeper/widgets/tables/base_table.dart'; +import 'package:time_keeper/widgets/tables/edit_table.dart'; + +class LocationsView extends ConsumerWidget { + const LocationsView({super.key}); + + void _showClearDialog( + BuildContext context, + WidgetRef ref, + Map locations, + ) { + final ids = locations.keys.toList(); + if (ids.isEmpty) { + SnackBarDialog.info(message: 'No locations to delete').show(context); + return; + } + + ConfirmDialog.warn( + title: 'Clear All Locations', + message: Text( + 'Are you sure you want to delete all locations? ' + '(${ids.length} ${ids.length == 1 ? 'location' : 'locations'})', + ), + confirmText: 'Delete', + onConfirmAsync: () async { + final client = ref.read(locationServiceProvider); + for (final id in ids) { + await client.deleteLocation(DeleteLocationRequest(id: id)); + } + }, + showResultDialog: true, + successMessage: Text('Deleted ${ids.length} locations'), + ).show(context); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locations = ref.watch(locationsProvider); + final theme = Theme.of(context); + + final sorted = locations.entries.toList() + ..sort((a, b) => a.value.location.compareTo(b.value.location)); + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Locations', style: theme.textTheme.headlineMedium), + const Spacer(), + OutlinedButton.icon( + onPressed: () => _showClearDialog(context, ref, locations), + icon: Icon(Icons.delete_sweep, size: 18, color: Colors.red), + label: Text('Clear All', style: TextStyle(color: Colors.red)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Colors.red), + ), + ), + ], + ), + const SizedBox(height: 24), + Expanded( + child: EditTable( + alternatingRows: true, + headers: [ + BaseTableCell( + child: Text( + 'Location Name', + style: TextStyle(color: Colors.white), + ), + flex: 3, + ), + ], + headerDecoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + editRows: sorted.map((entry) { + final id = entry.key; + final location = entry.value; + return EditTableRow( + key: ValueKey(id), + onEdit: () => showLocationDialog( + context, + ref, + id: id, + existingName: location.location, + ), + onDelete: () => showDeleteLocationDialog( + context, + ref, + id: id, + name: location.location, + ), + cells: [ + BaseTableCell(child: Text(location.location), flex: 3), + ], + ); + }).toList(), + onAdd: () => showLocationDialog(context, ref), + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/views/sessions/session_calendar.dart b/client/lib/views/sessions/session_calendar.dart new file mode 100644 index 0000000..2f14fd2 --- /dev/null +++ b/client/lib/views/sessions/session_calendar.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/models/session_status.dart'; +import 'package:time_keeper/utils/time.dart'; + +class SessionCalendar extends HookWidget { + final Map sessions; + final DateTime? selectedDate; + final void Function(DateTime date) onDateSelected; + + const SessionCalendar({ + super.key, + required this.sessions, + required this.selectedDate, + required this.onDateSelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final now = DateTime.now(); + final focusedDay = useState(selectedDate ?? now); + + // Build event map: normalized date -> list of sessions + final eventMap = >{}; + for (final session in sessions.values) { + final dt = session.startTime.toDateTime(); + final key = DateTime(dt.year, dt.month, dt.day); + eventMap.putIfAbsent(key, () => []).add(session); + } + + List getEventsForDay(DateTime day) { + final key = DateTime(day.year, day.month, day.day); + return eventMap[key] ?? []; + } + + return Container( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: TableCalendar( + firstDay: DateTime(2020), + lastDay: DateTime(2030), + focusedDay: focusedDay.value, + selectedDayPredicate: (day) => + selectedDate != null && isSameDay(selectedDate, day), + eventLoader: getEventsForDay, + startingDayOfWeek: StartingDayOfWeek.monday, + calendarFormat: CalendarFormat.month, + availableCalendarFormats: const {CalendarFormat.month: 'Month'}, + onDaySelected: (selected, focused) { + focusedDay.value = focused; + onDateSelected(selected); + }, + onPageChanged: (focused) { + focusedDay.value = focused; + }, + + // Styling + headerStyle: HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: theme.textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + leftChevronIcon: Icon( + Icons.chevron_left, + color: theme.colorScheme.onSurface, + ), + rightChevronIcon: Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface, + ), + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: theme.textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant, + ), + weekendStyle: theme.textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + calendarStyle: CalendarStyle( + outsideDaysVisible: false, + todayDecoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + todayTextStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + selectedDecoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + selectedTextStyle: TextStyle( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + markersMaxCount: 3, + markerSize: 6, + markersAlignment: Alignment.bottomCenter, + markerMargin: const EdgeInsets.symmetric(horizontal: 1), + ), + + // Custom marker builder for session status colors + calendarBuilders: CalendarBuilders( + markerBuilder: (context, day, events) { + if (events.isEmpty) return null; + return Positioned( + bottom: 4, + child: Row( + mainAxisSize: MainAxisSize.min, + children: events.take(3).map((session) { + final status = getSessionStatus(session); + return Container( + width: 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: statusColor(status), + shape: BoxShape.circle, + ), + ); + }).toList(), + ), + ); + }, + ), + ), + ); + } +} diff --git a/client/lib/views/sessions/session_detail_dialog.dart b/client/lib/views/sessions/session_detail_dialog.dart new file mode 100644 index 0000000..93fcd5d --- /dev/null +++ b/client/lib/views/sessions/session_detail_dialog.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +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/utils/formatting.dart'; +import 'package:time_keeper/utils/time.dart'; +import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; + +void showSessionDetailDialog( + BuildContext context, + WidgetRef ref, { + required String sessionId, + required Session session, + required Map locations, + required Map teamMembers, +}) { + final start = session.startTime.toDateTime(); + final end = session.endTime.toDateTime(); + final duration = end.difference(start); + final locationName = + locations[session.locationId]?.location ?? session.locationId; + final status = getSessionStatus(session); + + final memberSessions = session.memberSessions.toList() + ..sort((a, b) { + final aTime = a.hasCheckInTime() + ? a.checkInTime.toDateTime() + : DateTime(0); + final bTime = b.hasCheckInTime() + ? b.checkInTime.toDateTime() + : DateTime(0); + return aTime.compareTo(bTime); + }); + + final checkedOutCount = memberSessions + .where((ms) => ms.hasCheckInTime() && ms.hasCheckOutTime()) + .length; + + PopupDialog.info( + title: 'Session Details', + message: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoRow(label: 'Date', value: formatDate(start)), + _InfoRow( + label: 'Time', + value: '${formatTime(start)} - ${formatTime(end)}', + ), + _InfoRow(label: 'Duration', value: formatDuration(duration)), + _InfoRow(label: 'Location', value: locationName), + _InfoRow(label: 'Status', value: statusLabel(status)), + const Divider(height: 24), + + Text( + 'Members (${memberSessions.length} total, $checkedOutCount completed)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + + if (memberSessions.isEmpty) + const Text('No members checked in') + 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; + + 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(), + ); + } + + 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, + ), + ), + if (memberDuration != null) ...[ + const SizedBox(width: 12), + Text( + formatDuration(memberDuration), + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ], + ), + ); + }, + ), + ), + ], + ), + ), + ).show(context); +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/client/lib/views/sessions/session_dialog.dart b/client/lib/views/sessions/session_dialog.dart new file mode 100644 index 0000000..997097d --- /dev/null +++ b/client/lib/views/sessions/session_dialog.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/generated/api/session.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/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'; + +void showSessionDialog( + BuildContext context, + WidgetRef ref, { + String? id, + Session? existingSession, +}) { + final isEdit = id != null; + + PopupDialog.info( + title: isEdit ? 'Edit Session' : 'Create Session', + message: _SessionForm( + ref: ref, + isEdit: isEdit, + sessionId: id, + existingSession: existingSession, + ), + actions: const [], + ).show(context); +} + +void showDeleteSessionDialog( + BuildContext context, + WidgetRef ref, { + required String id, + required Session session, +}) { + final start = session.startTime.toDateTime(); + final label = '${formatDate(start)} ${formatTime(start)}'; + + ConfirmDialog.warn( + title: 'Delete Session', + message: Text('Are you sure you want to delete the session on $label?'), + confirmText: 'Delete', + onConfirmAsyncGrpc: () async { + final client = ref.read(sessionServiceProvider); + return await callGrpcEndpoint( + () => client.deleteSession(DeleteSessionRequest(id: id)), + ); + }, + showResultDialog: true, + successMessage: const Text('Session has been deleted'), + ).show(context); +} + +class _SessionForm extends HookWidget { + final WidgetRef ref; + 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) { + final locations = ref.watch(locationsProvider); + + final now = DateTime.now(); + final existingStart = existingSession?.startTime.toDateTime(); + final existingEnd = existingSession?.endTime.toDateTime(); + + final startDate = useState(existingStart ?? now); + final startTime = useState(TimeOfDay.fromDateTime(existingStart ?? now)); + final endDate = useState(existingEnd ?? now.add(const Duration(hours: 2))); + final endTime = useState( + TimeOfDay.fromDateTime(existingEnd ?? now.add(const Duration(hours: 2))), + ); + final selectedLocationId = useState(existingSession?.locationId); + final finished = useState(existingSession?.finished ?? false); + + final locationEntries = locations.entries.toList() + ..sort((a, b) => a.value.location.compareTo(b.value.location)); + + // Default to first location if none selected + if (selectedLocationId.value == null && locationEntries.isNotEmpty) { + selectedLocationId.value = locationEntries.first.key; + } + + return SizedBox( + width: 450, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Start date/time + Text('Start', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _DatePickerField( + label: 'Date', + value: startDate.value, + onChanged: (d) => startDate.value = d, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TimePickerField( + label: 'Time', + value: startTime.value, + onChanged: (t) => startTime.value = t, + ), + ), + ], + ), + const SizedBox(height: 16), + + // End date/time + Text('End', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _DatePickerField( + label: 'Date', + value: endDate.value, + onChanged: (d) => endDate.value = d, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TimePickerField( + label: 'Time', + value: endTime.value, + onChanged: (t) => endTime.value = t, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Location + Text('Location', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selectedLocationId.value, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: locationEntries + .map( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value.location), + ), + ) + .toList(), + onChanged: (value) => selectedLocationId.value = value, + ), + + // Finished toggle (only for edit) + if (isEdit) ...[ + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Finished'), + value: finished.value, + onChanged: (value) => finished.value = value, + contentPadding: EdgeInsets.zero, + ), + ], + + const SizedBox(height: 24), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + 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, + ); + + 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(); + + 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'), + ), + ], + ), + ], + ), + ); + } +} + +class _DatePickerField extends StatelessWidget { + final String label; + final DateTime value; + final ValueChanged onChanged; + + const _DatePickerField({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: value, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) onChanged(picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.calendar_today, size: 18), + ), + child: Text('${value.day}/${value.month}/${value.year}'), + ), + ); + } +} + +class _TimePickerField extends StatelessWidget { + final String label; + final TimeOfDay value; + final ValueChanged onChanged; + + const _TimePickerField({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: value, + ); + if (picked != null) onChanged(picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.access_time, size: 18), + ), + child: Text(value.format(context)), + ), + ); + } +} diff --git a/client/lib/views/sessions/session_stats.dart b/client/lib/views/sessions/session_stats.dart new file mode 100644 index 0000000..9878703 --- /dev/null +++ b/client/lib/views/sessions/session_stats.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/models/session_status.dart'; +import 'package:time_keeper/utils/time.dart'; +import 'package:time_keeper/widgets/stat_card.dart'; + +class SessionStats extends StatelessWidget { + final Map sessions; + + const SessionStats({super.key, required this.sessions}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final now = DateTime.now(); + + final activeSessions = sessions.values.where((s) { + final status = getSessionStatus(s); + return status == SessionStatus.current || + status == SessionStatus.overtime; + }).length; + final upcomingSessions = sessions.values + .where((s) => getSessionStatus(s) == SessionStatus.upcoming) + .length; + final thisMonth = sessions.values.where((s) { + final dt = s.startTime.toDateTime(); + return dt.year == now.year && dt.month == now.month; + }).length; + + final uniqueMembers = {}; + for (final session in sessions.values) { + for (final ms in session.memberSessions) { + uniqueMembers.add(ms.teamMemberId); + } + } + + return Row( + children: [ + StatCard( + icon: Icons.event, + label: 'Total', + value: '${sessions.length}', + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + StatCard( + icon: Icons.play_circle, + label: 'Active', + value: '$activeSessions', + color: Colors.green, + ), + const SizedBox(width: 12), + StatCard( + icon: Icons.schedule, + label: 'Upcoming', + value: '$upcomingSessions', + color: Colors.blue, + ), + const SizedBox(width: 12), + StatCard( + icon: Icons.calendar_month, + label: 'This Month', + value: '$thisMonth', + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: 12), + StatCard( + icon: Icons.people, + label: 'Unique Members', + value: '${uniqueMembers.length}', + color: theme.colorScheme.secondary, + ), + ], + ); + } +} diff --git a/client/lib/views/sessions/session_table.dart b/client/lib/views/sessions/session_table.dart new file mode 100644 index 0000000..7fb9fbb --- /dev/null +++ b/client/lib/views/sessions/session_table.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +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/views/sessions/session_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'; +import 'package:time_keeper/widgets/tables/edit_table.dart'; + +class SessionTable extends ConsumerWidget { + final List> sessions; + + const SessionTable({super.key, required this.sessions}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locations = ref.watch(locationsProvider); + final teamMembers = ref.watch(teamMembersProvider); + final theme = Theme.of(context); + + return EditTable( + alternatingRows: true, + headers: [ + BaseTableCell( + child: Text('Date', style: TextStyle(color: Colors.white)), + flex: 2, + ), + BaseTableCell( + child: Text('Time', style: TextStyle(color: Colors.white)), + flex: 2, + ), + BaseTableCell( + child: Text('Duration', style: TextStyle(color: Colors.white)), + ), + 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)), + ), + ], + headerDecoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + ), + editRows: 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 EditTableRow( + key: ValueKey(id), + onEdit: () => + showSessionDialog(context, ref, id: id, existingSession: session), + onDelete: () => + showDeleteSessionDialog(context, ref, id: id, session: session), + cells: [ + BaseTableCell(child: Text(formatDate(start)), flex: 2), + BaseTableCell( + child: Text('${formatTime(start)} - ${formatTime(end)}'), + flex: 2, + ), + 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(), + onAdd: () => showSessionDialog(context, ref), + ); + } +} diff --git a/client/lib/views/sessions/session_view.dart b/client/lib/views/sessions/session_view.dart new file mode 100644 index 0000000..76eeeb6 --- /dev/null +++ b/client/lib/views/sessions/session_view.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/generated/api/session.pbgrpc.dart'; +import 'package:time_keeper/models/session_status.dart'; +import 'package:time_keeper/providers/session_provider.dart'; +import 'package:time_keeper/utils/formatting.dart'; +import 'package:time_keeper/utils/time.dart'; +import 'package:time_keeper/views/sessions/session_calendar.dart'; +import 'package:time_keeper/views/sessions/session_stats.dart'; +import 'package:time_keeper/views/sessions/session_table.dart'; +import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; + +class SessionView extends HookConsumerWidget { + const SessionView({super.key}); + + void _showClearDialog( + BuildContext context, + WidgetRef ref, + Map sessions, + ) { + final ids = sessions.keys.toList(); + if (ids.isEmpty) { + SnackBarDialog.info(message: 'No sessions to delete').show(context); + return; + } + + ConfirmDialog.warn( + title: 'Clear All Sessions', + message: Text( + 'Are you sure you want to delete all sessions? ' + '(${ids.length} ${ids.length == 1 ? 'session' : 'sessions'})', + ), + confirmText: 'Delete', + onConfirmAsync: () async { + final client = ref.read(sessionServiceProvider); + for (final id in ids) { + await client.deleteSession(DeleteSessionRequest(id: id)); + } + }, + showResultDialog: true, + successMessage: Text('Deleted ${ids.length} sessions'), + ).show(context); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessions = ref.watch(sessionsProvider); + final theme = Theme.of(context); + + final showCalendar = useState(true); + final selectedDate = useState(null); + + // Sort: current first, then upcoming, then passed + final sorted = sessions.entries.toList()..sort(compareSessionEntries); + + // Filter by selected calendar date + final filtered = selectedDate.value != null + ? sorted.where((entry) { + final dt = entry.value.startTime.toDateTime(); + final sel = selectedDate.value!; + return dt.year == sel.year && + dt.month == sel.month && + dt.day == sel.day; + }).toList() + : sorted; + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Text('Sessions', style: theme.textTheme.headlineMedium), + const Spacer(), + OutlinedButton.icon( + onPressed: () => _showClearDialog(context, ref, sessions), + icon: Icon(Icons.delete_sweep, size: 18, color: Colors.red), + label: Text('Clear All', style: TextStyle(color: Colors.red)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Colors.red), + ), + ), + const SizedBox(width: 12), + SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + icon: Icon(Icons.calendar_month), + label: Text('Calendar'), + ), + ButtonSegment( + value: false, + icon: Icon(Icons.table_rows), + label: Text('Table'), + ), + ], + selected: {showCalendar.value}, + onSelectionChanged: (value) { + showCalendar.value = value.first; + if (!value.first) selectedDate.value = null; + }, + ), + ], + ), + const SizedBox(height: 24), + + // Calendar + if (showCalendar.value) ...[ + SessionCalendar( + sessions: sessions, + selectedDate: selectedDate.value, + onDateSelected: (date) { + if (selectedDate.value == date) { + selectedDate.value = null; + } else { + selectedDate.value = date; + } + }, + ), + if (selectedDate.value != null) + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Row( + children: [ + Text( + 'Showing: ${formatDate(selectedDate.value!)}', + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => selectedDate.value = null, + icon: const Icon(Icons.clear, size: 16), + label: const Text('Clear filter'), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Stats + SessionStats(sessions: sessions), + const SizedBox(height: 16), + + // Table + Expanded(child: SessionTable(sessions: filtered)), + ], + ), + ); + } +} diff --git a/client/lib/views/setup/common/dropdown_setting.dart b/client/lib/views/setup/common/dropdown_setting.dart index 6c0db46..164200a 100644 --- a/client/lib/views/setup/common/dropdown_setting.dart +++ b/client/lib/views/setup/common/dropdown_setting.dart @@ -31,8 +31,19 @@ class DropdownSetting extends StatelessWidget { child: DropdownButtonFormField( initialValue: value, decoration: const InputDecoration(border: OutlineInputBorder()), + isExpanded: true, items: items, onChanged: onChanged, + selectedItemBuilder: (context) => items.map((item) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + (item.child is Text) ? (item.child as Text).data ?? '' : '', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + }).toList(), ), ), const SizedBox(width: 12), diff --git a/client/lib/views/setup/database_setup.dart b/client/lib/views/setup/database_setup.dart new file mode 100644 index 0000000..d912cbc --- /dev/null +++ b/client/lib/views/setup/database_setup.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/generated/api/settings.pbgrpc.dart'; +import 'package:time_keeper/helpers/grpc_call_wrapper.dart'; +import 'package:time_keeper/providers/settings_provider.dart'; +import 'package:time_keeper/views/setup/common/locked_button_setting.dart'; +import 'package:time_keeper/views/setup/common/settings_page_layout.dart'; +import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; + +class DatabaseSetupTab extends ConsumerWidget { + const DatabaseSetupTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SettingsPageLayout( + title: 'Database', + subtitle: 'Manage database operations', + children: [ + LockedButtonSetting.danger( + label: 'Purge Database', + description: 'Permanently delete all data from the database.', + actionButtonLabel: 'Purge', + actionIcon: Icons.delete_forever, + noticeMessage: + 'WARNING: This will permanently delete ALL data including ' + 'sessions, team members, locations, users, and settings. ' + 'Only the default admin account will be recreated. ' + 'This action cannot be undone!', + onAction: () { + ConfirmDialog.error( + title: 'Confirm Database Purge', + message: const Text( + 'Are you absolutely sure you want to purge the entire database? ' + 'This will delete all data and cannot be undone.', + ), + onConfirmAsyncGrpc: () async { + return await callGrpcEndpoint( + () => ref + .read(settingsServiceProvider) + .purgeDatabase(PurgeDatabaseRequest()), + ); + }, + showResultDialog: true, + successMessage: const Text('Database purged successfully'), + ).show(context); + }, + ), + ], + ); + } +} diff --git a/client/lib/views/setup/setup_view.dart b/client/lib/views/setup/setup_view.dart index d914b3f..414e311 100644 --- a/client/lib/views/setup/setup_view.dart +++ b/client/lib/views/setup/setup_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:time_keeper/views/setup/database_setup.dart'; import 'package:time_keeper/views/setup/member_setup.dart'; import 'package:time_keeper/views/setup/session_setup.dart'; @@ -18,13 +19,13 @@ class SetupView extends HookConsumerWidget { tabs: const [ Tab(text: 'Session Setup'), Tab(text: 'Team Member Setup'), - Tab(text: 'Database Setup'), + Tab(text: 'Database'), ], ), Expanded( child: TabBarView( controller: tabController, - children: [SessionSetupTab(), MemberSetupTab(), Text('3')], + children: [SessionSetupTab(), MemberSetupTab(), DatabaseSetupTab()], ), ), ], diff --git a/client/lib/views/stats/stats_attendance_chart.dart b/client/lib/views/stats/stats_attendance_chart.dart new file mode 100644 index 0000000..04fdcd2 --- /dev/null +++ b/client/lib/views/stats/stats_attendance_chart.dart @@ -0,0 +1,203 @@ +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'; + +class StatsAttendanceChart extends StatelessWidget { + final List dailyAttendance; + final DateTime? selectedDay; + final ValueChanged onDaySelected; + + const StatsAttendanceChart({ + super.key, + required this.dailyAttendance, + required this.selectedDay, + required this.onDaySelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final barColor = theme.colorScheme.secondary; + final selectedColor = theme.colorScheme.primary; + final textColor = theme.colorScheme.onSurface; + final gridColor = theme.colorScheme.outlineVariant; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.people, size: 18, color: barColor), + const SizedBox(width: 8), + Text( + 'People per Day', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (selectedDay != null) ...[ + const Spacer(), + TextButton.icon( + onPressed: () => onDaySelected(null), + icon: const Icon(Icons.clear, size: 14), + label: const Text('Clear selection'), + ), + ], + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 220, + child: dailyAttendance.isEmpty + ? Center( + child: Text( + 'No data for this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: _maxY(), + barGroups: dailyAttendance.asMap().entries.map((entry) { + final i = entry.key; + final d = entry.value; + final isSelected = + selectedDay != null && + d.date.year == selectedDay!.year && + d.date.month == selectedDay!.month && + d.date.day == selectedDay!.day; + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: d.uniqueMembers.toDouble(), + color: isSelected ? selectedColor : barColor, + width: dailyAttendance.length > 14 ? 8 : 16, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ); + }).toList(), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= dailyAttendance.length) { + return const SizedBox(); + } + final d = dailyAttendance[idx].date; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${weekdayAbbr[d.weekday - 1]} ${d.day}', + style: TextStyle( + fontSize: 10, + color: textColor, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: _gridInterval(), + getTitlesWidget: (value, meta) { + if (value != value.roundToDouble()) { + return const SizedBox(); + } + return Text( + '${value.toInt()}', + style: TextStyle( + fontSize: 10, + color: textColor, + ), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _gridInterval(), + getDrawingHorizontalLine: (value) => + FlLine(color: gridColor, strokeWidth: 0.5), + ), + barTouchData: BarTouchData( + touchCallback: (event, response) { + if (event is FlTapUpEvent && + response?.spot != null) { + final idx = response!.spot!.touchedBarGroupIndex; + if (idx >= 0 && idx < dailyAttendance.length) { + final tappedDate = dailyAttendance[idx].date; + if (selectedDay != null && + tappedDate.year == selectedDay!.year && + tappedDate.month == selectedDay!.month && + tappedDate.day == selectedDay!.day) { + onDaySelected(null); + } else { + onDaySelected(tappedDate); + } + } + } + }, + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final d = dailyAttendance[group.x]; + return BarTooltipItem( + '${d.uniqueMembers} members', + const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + double _maxY() { + double max = 0; + for (final d in dailyAttendance) { + if (d.uniqueMembers > max) max = d.uniqueMembers.toDouble(); + } + return (max * 1.2).ceilToDouble().clamp(1, double.infinity); + } + + double _gridInterval() { + final max = _maxY(); + if (max <= 5) return 1; + if (max <= 15) return 2; + if (max <= 30) return 5; + return 10; + } +} diff --git a/client/lib/views/stats/stats_day_detail.dart b/client/lib/views/stats/stats_day_detail.dart new file mode 100644 index 0000000..e43df80 --- /dev/null +++ b/client/lib/views/stats/stats_day_detail.dart @@ -0,0 +1,185 @@ +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/widgets/tables/header_text.dart'; + +class StatsDayDetail extends StatelessWidget { + final DateTime selectedDay; + final List members; + final VoidCallback onClose; + + const StatsDayDetail({ + super.key, + required this.selectedDay, + required this.members, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final evenColor = isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.02); + final oddColor = isDark + ? Colors.white.withValues(alpha: 0.07) + : Colors.black.withValues(alpha: 0.05); + + final weekday = weekdayFull[selectedDay.weekday - 1]; + final month = monthAbbr[selectedDay.month - 1]; + final dateStr = '$weekday, $month ${selectedDay.day}'; + + double totalRegular = 0; + double totalOvertime = 0; + for (final m in members) { + totalRegular += m.regularSecs; + totalOvertime += m.overtimeSecs; + } + + return Card( + color: theme.colorScheme.primary.withValues(alpha: 0.05), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.calendar_today, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + dateStr, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 16), + Text( + '${members.length} members', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + '${formatSecsAsHoursMinutes(totalRegular)} regular', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + if (totalOvertime > 0) ...[ + const SizedBox(width: 8), + Text( + '+${formatSecsAsHoursMinutes(totalOvertime)} overtime', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const Spacer(), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, size: 18), + tooltip: 'Close', + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + const SizedBox(height: 12), + if (members.isEmpty) + Center( + child: Text( + 'No check-ins on this day', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else ...[ + // Header row + Container( + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: const Row( + children: [ + Expanded(flex: 3, child: TableHeaderText('Member')), + Expanded(flex: 2, child: TableHeaderText('Type')), + Expanded(flex: 2, child: TableHeaderText('Regular')), + Expanded(flex: 2, child: TableHeaderText('Overtime')), + Expanded(flex: 2, child: TableHeaderText('Total')), + ], + ), + ), + // Member rows + ...members.asMap().entries.map((entry) { + final i = entry.key; + final m = entry.value; + final memberType = m.memberType == TeamMemberType.STUDENT + ? 'Student' + : 'Mentor'; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: i % 2 == 0 ? evenColor : oddColor, + ), + child: Row( + children: [ + Expanded(flex: 3, child: Text(m.name)), + Expanded(flex: 2, child: Text(memberType)), + Expanded( + flex: 2, + child: Text(formatSecsAsHoursMinutes(m.regularSecs)), + ), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.overtimeSecs), + style: m.overtimeSecs > 0 + ? TextStyle(color: theme.colorScheme.error) + : null, + ), + ), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.totalSecs), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + }), + ], + ], + ), + ), + ); + } +} diff --git a/client/lib/views/stats/stats_helpers.dart b/client/lib/views/stats/stats_helpers.dart new file mode 100644 index 0000000..b536294 --- /dev/null +++ b/client/lib/views/stats/stats_helpers.dart @@ -0,0 +1,336 @@ +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/models/stats_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/utils/formatting.dart' + show formatSecsAsHoursMinutes; + +/// Regular vs overtime for a single member session. +({double regularSecs, double overtimeSecs}) computeMemberSessionHours( + TeamMemberSession ms, + Session session, +) { + final sessionStart = session.startTime.toDateTime(); + final sessionEnd = session.endTime.toDateTime(); + final checkIn = ms.checkInTime.toDateTime(); + final checkOut = ms.hasCheckOutTime() + ? ms.checkOutTime.toDateTime() + : DateTime.now(); + + final totalSecs = checkOut.difference(checkIn).inSeconds.toDouble(); + if (totalSecs <= 0) return (regularSecs: 0, overtimeSecs: 0); + + final overlapStart = checkIn.isAfter(sessionStart) ? checkIn : sessionStart; + final overlapEnd = checkOut.isBefore(sessionEnd) ? checkOut : sessionEnd; + final regularSecs = overlapEnd.isAfter(overlapStart) + ? overlapEnd.difference(overlapStart).inSeconds.toDouble() + : 0.0; + + return (regularSecs: regularSecs, overtimeSecs: totalSecs - regularSecs); +} + +Map computeMemberHours( + Map sessions, + Map teamMembers, +) { + final result = {}; + + for (final session in sessions.values) { + if (!session.hasStartTime() || !session.hasEndTime()) continue; + for (final ms in session.memberSessions) { + if (!ms.hasCheckInTime()) continue; + final member = teamMembers[ms.teamMemberId]; + final name = member != null + ? '${member.firstName} ${member.lastName}' + : ms.teamMemberId; + final memberType = member?.memberType ?? TeamMemberType.STUDENT; + + final (:regularSecs, :overtimeSecs) = computeMemberSessionHours( + ms, + session, + ); + + final entry = result.putIfAbsent( + ms.teamMemberId, + () => MemberHoursData( + memberId: ms.teamMemberId, + name: name, + memberType: memberType, + ), + ); + entry.regularSecs += regularSecs; + entry.overtimeSecs += overtimeSecs; + } + } + + return result; +} + +List computeDailyHours(Map sessions) { + final byDay = {}; + + for (final session in sessions.values) { + if (!session.hasStartTime() || !session.hasEndTime()) continue; + for (final ms in session.memberSessions) { + if (!ms.hasCheckInTime()) continue; + final (:regularSecs, :overtimeSecs) = computeMemberSessionHours( + ms, + session, + ); + final date = ms.checkInTime.toDateTime(); + final key = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + + byDay.putIfAbsent( + key, + () => DayHoursData(date: DateTime(date.year, date.month, date.day)), + ); + byDay[key]!.regularSecs += regularSecs; + byDay[key]!.overtimeSecs += overtimeSecs; + } + } + + return byDay.values.toList()..sort((a, b) => a.date.compareTo(b.date)); +} + +List computeDailyAttendance(Map sessions) { + final byDay = >{}; + final dates = {}; + + for (final session in sessions.values) { + for (final ms in session.memberSessions) { + if (!ms.hasCheckInTime()) continue; + final date = ms.checkInTime.toDateTime(); + final key = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + byDay.putIfAbsent(key, () => {}); + byDay[key]!.add(ms.teamMemberId); + dates.putIfAbsent(key, () => DateTime(date.year, date.month, date.day)); + } + } + + final result = + byDay.entries + .map( + (e) => DayAttendanceData( + date: dates[e.key]!, + uniqueMembers: e.value.length, + ), + ) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + return result; +} + +List computeDayMemberDetails( + DateTime day, + Map sessions, + Map teamMembers, +) { + final accum = {}; + + for (final session in sessions.values) { + if (!session.hasStartTime() || !session.hasEndTime()) continue; + for (final ms in session.memberSessions) { + if (!ms.hasCheckInTime()) continue; + final checkInDate = ms.checkInTime.toDateTime(); + if (checkInDate.year != day.year || + checkInDate.month != day.month || + checkInDate.day != day.day) { + continue; + } + + final member = teamMembers[ms.teamMemberId]; + final name = member != null + ? '${member.firstName} ${member.lastName}' + : ms.teamMemberId; + final memberType = member?.memberType ?? TeamMemberType.STUDENT; + final (:regularSecs, :overtimeSecs) = computeMemberSessionHours( + ms, + session, + ); + + final existing = accum[ms.teamMemberId]; + if (existing != null) { + accum[ms.teamMemberId] = DayMemberDetail( + memberId: ms.teamMemberId, + name: name, + memberType: memberType, + regularSecs: existing.regularSecs + regularSecs, + overtimeSecs: existing.overtimeSecs + overtimeSecs, + ); + } else { + accum[ms.teamMemberId] = DayMemberDetail( + memberId: ms.teamMemberId, + name: name, + memberType: memberType, + regularSecs: regularSecs, + overtimeSecs: overtimeSecs, + ); + } + } + } + + return accum.values.toList() + ..sort((a, b) => b.totalSecs.compareTo(a.totalSecs)); +} + +List computeLocationAttendance( + Map sessions, + Map locations, +) { + final byLocation = {}; + + for (final session in sessions.values) { + final locId = session.locationId; + final locName = locations[locId]?.location ?? locId; + final count = session.memberSessions + .where((ms) => ms.hasCheckInTime()) + .length; + if (count == 0) continue; + + byLocation.putIfAbsent( + locId, + () => LocationAttendanceData(locationId: locId, locationName: locName), + ); + byLocation[locId]!.checkInCount += count; + } + + return byLocation.values.toList() + ..sort((a, b) => b.checkInCount.compareTo(a.checkInCount)); +} + +AttendanceInsights computeInsights( + Map sessions, + Map locations, +) { + int totalCheckInMinutes = 0; + int totalCheckOutMinutes = 0; + int checkInCount = 0; + int checkOutCount = 0; + double totalVisitSecs = 0; + int visitCount = 0; + final memberIds = {}; + final dayOfWeekCounts = {}; + int totalAttendance = 0; + + for (final session in sessions.values) { + for (final ms in session.memberSessions) { + if (!ms.hasCheckInTime()) continue; + memberIds.add(ms.teamMemberId); + totalAttendance++; + + final checkIn = ms.checkInTime.toDateTime(); + totalCheckInMinutes += checkIn.hour * 60 + checkIn.minute; + checkInCount++; + + dayOfWeekCounts[checkIn.weekday] = + (dayOfWeekCounts[checkIn.weekday] ?? 0) + 1; + + if (ms.hasCheckOutTime()) { + final checkOut = ms.checkOutTime.toDateTime(); + totalCheckOutMinutes += checkOut.hour * 60 + checkOut.minute; + checkOutCount++; + + totalVisitSecs += checkOut.difference(checkIn).inSeconds; + visitCount++; + } + } + } + + String avgCheckIn = '-'; + if (checkInCount > 0) { + final avgMins = totalCheckInMinutes ~/ checkInCount; + avgCheckIn = formatTimeOfDay(avgMins ~/ 60, avgMins % 60); + } + + String avgCheckOut = '-'; + if (checkOutCount > 0) { + final avgMins = totalCheckOutMinutes ~/ checkOutCount; + avgCheckOut = formatTimeOfDay(avgMins ~/ 60, avgMins % 60); + } + + String avgVisit = '-'; + if (visitCount > 0) { + avgVisit = formatSecsAsHoursMinutes(totalVisitSecs / visitCount); + } + + String mostActive = '-'; + if (sessions.isNotEmpty) { + final locCounts = {}; + for (final session in sessions.values) { + final count = session.memberSessions + .where((ms) => ms.hasCheckInTime()) + .length; + if (count > 0) { + locCounts[session.locationId] = + (locCounts[session.locationId] ?? 0) + count; + } + } + if (locCounts.isNotEmpty) { + final topLocId = locCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + mostActive = locations[topLocId]?.location ?? topLocId; + } + } + + String busiest = '-'; + if (dayOfWeekCounts.isNotEmpty) { + final topDay = dayOfWeekCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + busiest = weekdayFull[topDay - 1]; + } + + final avgAttendance = sessions.isNotEmpty + ? totalAttendance / sessions.length + : 0.0; + + return AttendanceInsights( + avgCheckInTime: avgCheckIn, + avgCheckOutTime: avgCheckOut, + avgVisitDuration: avgVisit, + mostActiveLocation: mostActive, + busiestDay: busiest, + uniqueMembers: memberIds.length, + avgAttendancePerSession: avgAttendance, + ); +} + +/// Filter sessions by time range based on session start time. +Map filterSessionsByRange( + Map sessions, + StatsRange range, +) { + if (range == StatsRange.all) return sessions; + + final now = DateTime.now(); + late final DateTime start; + late final DateTime end; + + switch (range) { + case StatsRange.day: + start = DateTime(now.year, now.month, now.day); + end = DateTime(now.year, now.month, now.day + 1); + case StatsRange.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: + start = DateTime(now.year, now.month, 1); + end = DateTime(now.year, now.month + 1, 1); + case StatsRange.all: + return sessions; + } + + return Map.fromEntries( + sessions.entries.where((entry) { + if (!entry.value.hasStartTime()) return false; + final dt = entry.value.startTime.toDateTime(); + return !dt.isBefore(start) && dt.isBefore(end); + }), + ); +} diff --git a/client/lib/views/stats/stats_hours_chart.dart b/client/lib/views/stats/stats_hours_chart.dart new file mode 100644 index 0000000..2c66556 --- /dev/null +++ b/client/lib/views/stats/stats_hours_chart.dart @@ -0,0 +1,251 @@ +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'; + +class StatsHoursChart extends StatelessWidget { + final List dailyHours; + final DateTime? selectedDay; + final ValueChanged onDaySelected; + + const StatsHoursChart({ + super.key, + required this.dailyHours, + required this.selectedDay, + required this.onDaySelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + final errorColor = theme.colorScheme.error; + final textColor = theme.colorScheme.onSurface; + final gridColor = theme.colorScheme.outlineVariant; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.bar_chart, size: 18, color: primaryColor), + const SizedBox(width: 8), + Text( + 'Hours per Day', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (selectedDay != null) ...[ + TextButton.icon( + onPressed: () => onDaySelected(null), + icon: const Icon(Icons.clear, size: 14), + label: const Text('Clear selection'), + ), + const SizedBox(width: 8), + ], + _LegendDot(color: primaryColor, label: 'Regular'), + const SizedBox(width: 12), + _LegendDot(color: errorColor, label: 'Overtime'), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 220, + child: dailyHours.isEmpty + ? Center( + child: Text( + 'No data for this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: _maxY(), + barGroups: dailyHours.asMap().entries.map((entry) { + final i = entry.key; + final d = entry.value; + final isSelected = + selectedDay != null && + d.date.year == selectedDay!.year && + d.date.month == selectedDay!.month && + d.date.day == selectedDay!.day; + final opacity = selectedDay != null && !isSelected + ? 0.35 + : 1.0; + final regularHours = d.regularSecs / 3600; + final overtimeHours = d.overtimeSecs / 3600; + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: regularHours + overtimeHours, + rodStackItems: [ + BarChartRodStackItem( + 0, + regularHours, + primaryColor.withValues(alpha: opacity), + ), + if (overtimeHours > 0) + BarChartRodStackItem( + regularHours, + regularHours + overtimeHours, + errorColor.withValues(alpha: opacity), + ), + ], + width: dailyHours.length > 14 ? 8 : 16, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: Colors.transparent, + ), + ], + ); + }).toList(), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= dailyHours.length) { + return const SizedBox(); + } + final d = dailyHours[idx].date; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${weekdayAbbr[d.weekday - 1]} ${d.day}', + style: TextStyle( + fontSize: 10, + color: textColor, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 32, + getTitlesWidget: (value, meta) { + return Text( + '${value.toInt()}h', + style: TextStyle( + fontSize: 10, + color: textColor, + ), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _gridInterval(), + getDrawingHorizontalLine: (value) => + FlLine(color: gridColor, strokeWidth: 0.5), + ), + barTouchData: BarTouchData( + touchCallback: (event, response) { + if (event is FlTapUpEvent && + response?.spot != null) { + final idx = response!.spot!.touchedBarGroupIndex; + if (idx >= 0 && idx < dailyHours.length) { + final tappedDate = dailyHours[idx].date; + if (selectedDay != null && + tappedDate.year == selectedDay!.year && + tappedDate.month == selectedDay!.month && + tappedDate.day == selectedDay!.day) { + onDaySelected(null); + } else { + onDaySelected(tappedDate); + } + } + } + }, + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final d = dailyHours[group.x]; + final regular = formatSecsAsHoursMinutes( + d.regularSecs, + ); + final overtime = formatSecsAsHoursMinutes( + d.overtimeSecs, + ); + return BarTooltipItem( + 'Regular: $regular\nOvertime: $overtime', + TextStyle(color: Colors.white, fontSize: 12), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + double _maxY() { + double max = 0; + for (final d in dailyHours) { + final total = (d.regularSecs + d.overtimeSecs) / 3600; + if (total > max) max = total; + } + return (max * 1.2).ceilToDouble().clamp(1, double.infinity); + } + + double _gridInterval() { + final max = _maxY(); + if (max <= 4) return 1; + if (max <= 12) return 2; + if (max <= 24) return 4; + return 8; + } +} + +class _LegendDot extends StatelessWidget { + final Color color; + final String label; + + const _LegendDot({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} diff --git a/client/lib/views/stats/stats_location_chart.dart b/client/lib/views/stats/stats_location_chart.dart new file mode 100644 index 0000000..c853823 --- /dev/null +++ b/client/lib/views/stats/stats_location_chart.dart @@ -0,0 +1,127 @@ +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'; + +class StatsLocationChart extends StatelessWidget { + final List locationData; + + const StatsLocationChart({super.key, required this.locationData}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.pie_chart, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Attendance by Location', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 220, + child: locationData.isEmpty + ? Center( + child: Text( + 'No data for this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + sections: locationData.asMap().entries.map(( + entry, + ) { + final i = entry.key; + final loc = entry.value; + return PieChartSectionData( + value: loc.checkInCount.toDouble(), + color: vibrantColors(i), + title: '${loc.checkInCount}', + radius: 50, + titleStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ); + }).toList(), + centerSpaceRadius: 36, + sectionsSpace: 2, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: locationData.asMap().entries.map((entry) { + final i = entry.key; + final loc = entry.value; + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 3, + ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: vibrantColors(i), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + loc.locationName, + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${loc.checkInCount}', + style: theme.textTheme.bodySmall + ?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/views/stats/stats_member_hours_table.dart b/client/lib/views/stats/stats_member_hours_table.dart new file mode 100644 index 0000000..66ef9aa --- /dev/null +++ b/client/lib/views/stats/stats_member_hours_table.dart @@ -0,0 +1,159 @@ +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/widgets/tables/header_text.dart'; + +class StatsMemberHoursTable extends StatelessWidget { + final Map memberHours; + + const StatsMemberHoursTable({super.key, required this.memberHours}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final evenColor = isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.02); + final oddColor = isDark + ? Colors.white.withValues(alpha: 0.07) + : Colors.black.withValues(alpha: 0.05); + + final sorted = memberHours.values.toList() + ..sort((a, b) => b.totalSecs.compareTo(a.totalSecs)); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.people, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Member Hours', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + Text( + '${sorted.length} members', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + if (sorted.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + 'No member data for this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else ...[ + // Header + Container( + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: const Row( + children: [ + SizedBox(width: 32, child: TableHeaderText('#')), + Expanded(flex: 3, child: TableHeaderText('Member')), + Expanded(flex: 2, child: TableHeaderText('Type')), + Expanded(flex: 2, child: TableHeaderText('Regular')), + Expanded(flex: 2, child: TableHeaderText('Overtime')), + Expanded(flex: 2, child: TableHeaderText('Total')), + ], + ), + ), + // Rows + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + shrinkWrap: true, + itemCount: sorted.length, + itemBuilder: (context, i) { + final m = sorted[i]; + final memberType = m.memberType == TeamMemberType.STUDENT + ? 'Student' + : 'Mentor'; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: i % 2 == 0 ? evenColor : oddColor, + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + '${i + 1}', + style: TextStyle( + fontWeight: i < 3 + ? FontWeight.bold + : FontWeight.normal, + color: i < 3 ? theme.colorScheme.primary : null, + ), + ), + ), + Expanded(flex: 3, child: Text(m.name)), + Expanded(flex: 2, child: Text(memberType)), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.regularSecs), + ), + ), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.overtimeSecs), + style: m.overtimeSecs > 0 + ? TextStyle(color: theme.colorScheme.error) + : null, + ), + ), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.totalSecs), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/client/lib/views/stats/stats_overtime_table.dart b/client/lib/views/stats/stats_overtime_table.dart new file mode 100644 index 0000000..b6d3ece --- /dev/null +++ b/client/lib/views/stats/stats_overtime_table.dart @@ -0,0 +1,264 @@ +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/widgets/tables/header_text.dart'; + +class StatsOvertimeTable extends StatelessWidget { + final Map memberHours; + + const StatsOvertimeTable({super.key, required this.memberHours}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final evenColor = isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.02); + final oddColor = isDark + ? Colors.white.withValues(alpha: 0.07) + : Colors.black.withValues(alpha: 0.05); + final warningBg = Colors.orange.withValues(alpha: 0.15); + + final sorted = memberHours.values.toList() + ..sort((a, b) => b.overtimeSecs.compareTo(a.overtimeSecs)); + + // Only show members who have some overtime + final withOvertime = sorted.where((m) => m.overtimeSecs > 0).toList(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.flag, size: 18, color: theme.colorScheme.error), + const SizedBox(width: 8), + Text( + 'Overtime Flags', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + Text( + '${withOvertime.length} members with overtime', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + if (withOvertime.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + 'No overtime recorded in this period', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else ...[ + // Header + Container( + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: const Row( + children: [ + SizedBox(width: 32, child: TableHeaderText('#')), + Expanded(flex: 3, child: TableHeaderText('Member')), + Expanded(flex: 2, child: TableHeaderText('Type')), + Expanded(flex: 2, child: TableHeaderText('Regular')), + Expanded(flex: 2, child: TableHeaderText('Overtime')), + Expanded(flex: 1, child: TableHeaderText('% Overtime')), + ], + ), + ), + // Rows + ...withOvertime.asMap().entries.map((entry) { + final i = entry.key; + final m = entry.value; + final isHighOvertime = m.overtimePercent > 25; + final bgColor = isHighOvertime + ? warningBg + : (i % 2 == 0 ? evenColor : oddColor); + final memberType = m.memberType == TeamMemberType.STUDENT + ? 'Student' + : 'Mentor'; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration(color: bgColor), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + '${i + 1}', + style: TextStyle( + fontWeight: i < 3 + ? FontWeight.bold + : FontWeight.normal, + color: i < 3 ? theme.colorScheme.error : null, + ), + ), + ), + Expanded(flex: 3, child: Text(m.name)), + Expanded(flex: 2, child: Text(memberType)), + Expanded( + flex: 2, + child: Text(formatSecsAsHoursMinutes(m.regularSecs)), + ), + Expanded( + flex: 2, + child: Text( + formatSecsAsHoursMinutes(m.overtimeSecs), + style: TextStyle( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + flex: 1, + child: Text( + '${m.overtimePercent.toStringAsFixed(0)}%', + style: TextStyle( + color: isHighOvertime + ? theme.colorScheme.error + : null, + fontWeight: isHighOvertime + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), + ); + }), + ], + ], + ), + ), + ); + } +} + +class AttendanceInsightsCards extends StatelessWidget { + final AttendanceInsights insights; + + const AttendanceInsightsCards({super.key, required this.insights}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _InsightCard( + icon: Icons.login, + label: 'Avg Check-in', + value: insights.avgCheckInTime, + color: Colors.green, + ), + _InsightCard( + icon: Icons.logout, + label: 'Avg Check-out', + value: insights.avgCheckOutTime, + color: Colors.orange, + ), + _InsightCard( + icon: Icons.timelapse, + label: 'Avg Visit Duration', + value: insights.avgVisitDuration, + color: Colors.blue, + ), + _InsightCard( + icon: Icons.location_on, + label: 'Top Location', + value: insights.mostActiveLocation, + color: theme.colorScheme.tertiary, + ), + _InsightCard( + icon: Icons.calendar_today, + label: 'Busiest Day', + value: insights.busiestDay, + color: theme.colorScheme.primary, + ), + ], + ); + } +} + +class _InsightCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const _InsightCard({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/client/lib/views/stats/stats_overview_cards.dart b/client/lib/views/stats/stats_overview_cards.dart new file mode 100644 index 0000000..657c36a --- /dev/null +++ b/client/lib/views/stats/stats_overview_cards.dart @@ -0,0 +1,89 @@ +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/widgets/stat_card.dart'; + +class StatsOverviewCards extends StatelessWidget { + final Map filteredSessions; + final Map memberHours; + final AttendanceInsights insights; + + const StatsOverviewCards({ + super.key, + required this.filteredSessions, + required this.memberHours, + required this.insights, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + double totalRegular = 0; + double totalOvertime = 0; + for (final h in memberHours.values) { + totalRegular += h.regularSecs; + totalOvertime += h.overtimeSecs; + } + + // Average scheduled session duration + double avgSessionDuration = 0; + final sessionsWithTimes = filteredSessions.values + .where((s) => s.hasStartTime() && s.hasEndTime()) + .toList(); + if (sessionsWithTimes.isNotEmpty) { + double totalScheduledSecs = 0; + for (final s in sessionsWithTimes) { + totalScheduledSecs += s.endTime + .toDateTime() + .difference(s.startTime.toDateTime()) + .inSeconds; + } + avgSessionDuration = totalScheduledSecs / sessionsWithTimes.length; + } + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + StatCard( + icon: Icons.event, + label: 'Total Sessions', + value: '${filteredSessions.length}', + color: theme.colorScheme.primary, + ), + StatCard( + icon: Icons.schedule, + label: 'Total Hours', + value: formatSecsAsHoursMinutes(totalRegular + totalOvertime), + color: theme.colorScheme.secondary, + ), + StatCard( + icon: Icons.timer, + label: 'Avg Session Duration', + value: formatSecsAsHoursMinutes(avgSessionDuration), + color: Colors.blue, + ), + StatCard( + icon: Icons.warning_amber, + label: 'Total Overtime', + value: formatSecsAsHoursMinutes(totalOvertime), + color: theme.colorScheme.error, + ), + StatCard( + icon: Icons.people, + label: 'Unique Members', + value: '${insights.uniqueMembers}', + color: Colors.green, + ), + StatCard( + icon: Icons.groups, + label: 'Avg Attendance', + value: insights.avgAttendancePerSession.toStringAsFixed(1), + color: theme.colorScheme.tertiary, + ), + ], + ); + } +} diff --git a/client/lib/views/stats/stats_view.dart b/client/lib/views/stats/stats_view.dart new file mode 100644 index 0000000..7438d75 --- /dev/null +++ b/client/lib/views/stats/stats_view.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +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'; + +class StatsView extends HookConsumerWidget { + const StatsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessions = ref.watch(sessionsProvider); + final teamMembers = ref.watch(teamMembersProvider); + final locations = ref.watch(locationsProvider); + final theme = Theme.of(context); + + final selectedRange = useState(StatsRange.week); + final selectedDay = useState(null); + + final filtered = filterSessionsByRange(sessions, selectedRange.value); + final memberHours = computeMemberHours(filtered, teamMembers); + final dailyHours = computeDailyHours(filtered); + final dailyAttendance = computeDailyAttendance(filtered); + final locationAttendance = computeLocationAttendance(filtered, locations); + final insights = computeInsights(filtered, locations); + + final dayMemberDetails = selectedDay.value != null + ? computeDayMemberDetails(selectedDay.value!, filtered, teamMembers) + : []; + + void onDaySelected(DateTime? day) { + selectedDay.value = day; + } + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.analytics, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('Stats Dashboard', style: theme.textTheme.headlineMedium), + const Spacer(), + SegmentedButton( + segments: StatsRange.values + .map( + (r) => ButtonSegment( + value: r, + label: Text(statsRangeLabel(r)), + ), + ) + .toList(), + selected: {selectedRange.value}, + onSelectionChanged: (value) { + selectedRange.value = value.first; + selectedDay.value = null; + }, + ), + ], + ), + const SizedBox(height: 24), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Overview cards + StatsOverviewCards( + filteredSessions: filtered, + memberHours: memberHours, + insights: insights, + ), + const SizedBox(height: 24), + + // Charts row: Hours per Day + Location pie + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: StatsHoursChart( + dailyHours: dailyHours, + selectedDay: selectedDay.value, + onDaySelected: onDaySelected, + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: StatsLocationChart( + locationData: locationAttendance, + ), + ), + ], + ), + const SizedBox(height: 16), + + // People per day chart + StatsAttendanceChart( + dailyAttendance: dailyAttendance, + selectedDay: selectedDay.value, + onDaySelected: onDaySelected, + ), + const SizedBox(height: 16), + + // Day detail panel (shown when a day is selected) + if (selectedDay.value != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: StatsDayDetail( + selectedDay: selectedDay.value!, + members: dayMemberDetails, + onClose: () => selectedDay.value = null, + ), + ), + + // Attendance insights + AttendanceInsightsCards(insights: insights), + const SizedBox(height: 24), + + // Member hours table + StatsMemberHoursTable(memberHours: memberHours), + const SizedBox(height: 24), + + // Overtime flags table + StatsOvertimeTable(memberHours: memberHours), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/views/team/team_view.dart b/client/lib/views/team/team_view.dart index cd8ef33..d5d5923 100644 --- a/client/lib/views/team/team_view.dart +++ b/client/lib/views/team/team_view.dart @@ -6,9 +6,11 @@ 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/helpers/session_helper.dart'; import 'package:time_keeper/views/team/check_in_out_button.dart'; import 'package:time_keeper/views/team/member_type_chip.dart'; import 'package:time_keeper/views/team/team_member_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart'; import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; import 'package:time_keeper/widgets/tables/base_table.dart'; import 'package:time_keeper/widgets/tables/edit_table.dart'; @@ -16,17 +18,34 @@ import 'package:time_keeper/widgets/tables/edit_table.dart'; class TeamView extends ConsumerWidget { const TeamView({super.key}); - bool _isCheckedIn(String memberId, Map sessions) { - for (final session in sessions.values) { - for (final ms in session.memberSessions) { - if (ms.teamMemberId == memberId && - ms.hasCheckInTime() && - !ms.hasCheckOutTime()) { - return true; - } - } + void _showClearDialog( + BuildContext context, + WidgetRef ref, { + required String title, + required String description, + required List ids, + }) { + if (ids.isEmpty) { + SnackBarDialog.info(message: 'No members to delete').show(context); + return; } - return false; + + ConfirmDialog.warn( + title: title, + message: Text( + 'Are you sure you want to delete $description? ' + '(${ids.length} ${ids.length == 1 ? 'member' : 'members'})', + ), + confirmText: 'Delete', + onConfirmAsync: () async { + final client = ref.read(teamMemberServiceProvider); + for (final id in ids) { + await client.deleteTeamMember(DeleteTeamMemberRequest(id: id)); + } + }, + showResultDialog: true, + successMessage: Text('Deleted ${ids.length} members'), + ).show(context); } @override @@ -48,7 +67,58 @@ class TeamView extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Team Members', style: theme.textTheme.headlineMedium), + Row( + children: [ + Text('Team Members', style: theme.textTheme.headlineMedium), + const Spacer(), + _ClearButton( + label: 'Clear Students', + icon: Icons.school, + color: Colors.orange, + onPressed: () => _showClearDialog( + context, + ref, + title: 'Clear Students', + description: 'all students', + ids: teamMembers.entries + .where( + (e) => e.value.memberType == TeamMemberType.STUDENT, + ) + .map((e) => e.key) + .toList(), + ), + ), + const SizedBox(width: 8), + _ClearButton( + label: 'Clear Mentors', + icon: Icons.person, + color: Colors.orange, + onPressed: () => _showClearDialog( + context, + ref, + title: 'Clear Mentors', + description: 'all mentors', + ids: teamMembers.entries + .where((e) => e.value.memberType == TeamMemberType.MENTOR) + .map((e) => e.key) + .toList(), + ), + ), + const SizedBox(width: 8), + _ClearButton( + label: 'Clear All', + icon: Icons.delete_sweep, + color: Colors.red, + onPressed: () => _showClearDialog( + context, + ref, + title: 'Clear All Members', + description: 'all team members', + ids: teamMembers.keys.toList(), + ), + ), + ], + ), const SizedBox(height: 24), Expanded( child: EditTable( @@ -91,7 +161,7 @@ class TeamView extends ConsumerWidget { editRows: sorted.map((entry) { final id = entry.key; final member = entry.value; - final checkedIn = _isCheckedIn(id, sessions); + final checkedIn = isMemberCheckedIn(id, sessions.values); return EditTableRow( key: ValueKey(id), @@ -163,3 +233,27 @@ class TeamView extends ConsumerWidget { ); } } + +class _ClearButton extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onPressed; + + const _ClearButton({ + required this.label, + required this.icon, + required this.color, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18, color: color), + label: Text(label, style: TextStyle(color: color)), + style: OutlinedButton.styleFrom(side: BorderSide(color: color)), + ); + } +} diff --git a/client/lib/widgets/dialogs/confirm_dialog.dart b/client/lib/widgets/dialogs/confirm_dialog.dart index c62849c..e201c37 100644 --- a/client/lib/widgets/dialogs/confirm_dialog.dart +++ b/client/lib/widgets/dialogs/confirm_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:time_keeper/utils/grpc_result.dart'; import 'package:time_keeper/widgets/dialogs/base_dialog.dart'; import 'package:time_keeper/widgets/dialogs/popup_dialog.dart'; +import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart'; class ConfirmDialog extends BaseDialog { final PopupDialog _popupDialog; @@ -210,16 +211,17 @@ class _AsyncConfirmButton extends HookWidget { Navigator.of(context).pop(); if (showResultDialog) { - PopupDialog.success( - title: 'Success', - message: - successMessage ?? - const Text('Operation completed successfully'), + SnackBarDialog.success( + message: successMessage is Text + ? (successMessage as Text).data ?? 'Success' + : 'Success', ).show(context); } } } catch (e) { if (context.mounted) { + Navigator.of(context).pop(); + if (showResultDialog) { PopupDialog.error( title: 'Error', @@ -274,14 +276,22 @@ class _AsyncGrpcConfirmButton extends HookWidget { Navigator.of(context).pop(); if (showResultDialog) { - PopupDialog.fromGrpcStatus( - result: result, - successMessage: successMessage, - ).show(context); + switch (result) { + case GrpcSuccess(): + SnackBarDialog.success( + message: successMessage is Text + ? (successMessage as Text).data ?? 'Success' + : 'Success', + ).show(context); + case GrpcFailure(): + PopupDialog.fromGrpcStatus(result: result).show(context); + } } } } catch (e) { if (context.mounted) { + Navigator.of(context).pop(); + if (showResultDialog) { PopupDialog.error( title: 'Error', diff --git a/client/lib/widgets/member_count.dart b/client/lib/widgets/member_count.dart new file mode 100644 index 0000000..cf4ee53 --- /dev/null +++ b/client/lib/widgets/member_count.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:time_keeper/generated/db/db.pb.dart'; +import 'package:time_keeper/models/session_status.dart'; + +class MemberCount extends StatelessWidget { + final int total; + final SessionStatus status; + final Session session; + + const MemberCount({ + super.key, + required this.total, + required this.status, + required this.session, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeCount = session.memberSessions + .where((ms) => ms.hasCheckInTime() && !ms.hasCheckOutTime()) + .length; + final text = + status == SessionStatus.current || status == SessionStatus.overtime + ? '$activeCount / $total' + : '$total'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.people, size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text(text), + ], + ); + } +} diff --git a/client/lib/widgets/stat_card.dart b/client/lib/widgets/stat_card.dart new file mode 100644 index 0000000..4aee23a --- /dev/null +++ b/client/lib/widgets/stat_card.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +class StatCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const StatCard({ + super.key, + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/client/lib/widgets/status_chip.dart b/client/lib/widgets/status_chip.dart new file mode 100644 index 0000000..2fa9470 --- /dev/null +++ b/client/lib/widgets/status_chip.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:time_keeper/models/session_status.dart'; + +class SessionStatusChip extends StatelessWidget { + final SessionStatus status; + + const SessionStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + return Chip( + label: Text( + statusLabel(status), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: statusColor(status), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ); + } +} diff --git a/client/lib/widgets/tables/header_text.dart b/client/lib/widgets/tables/header_text.dart new file mode 100644 index 0000000..d848871 --- /dev/null +++ b/client/lib/widgets/tables/header_text.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class TableHeaderText extends StatelessWidget { + final String text; + const TableHeaderText(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 13, + ), + ); + } +} diff --git a/client/pubspec.lock b/client/pubspec.lock index fd2b1ab..a07bcbb 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -265,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -305,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -472,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -832,6 +856,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -909,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 1f6b5cc..97a2f21 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: file_picker: ^10.3.10 flutter_launcher_icons: ^0.14.4 fixnum: ^1.1.1 + table_calendar: ^3.2.0 + fl_chart: ^1.1.1 dev_dependencies: flutter_test: diff --git a/protos/api/api.proto b/protos/api/api.proto index 749f4ac..aeea3fd 100644 --- a/protos/api/api.proto +++ b/protos/api/api.proto @@ -6,6 +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/team_member.proto"; import public "api/user.proto"; diff --git a/protos/api/location.proto b/protos/api/location.proto index e87debc..8325168 100644 --- a/protos/api/location.proto +++ b/protos/api/location.proto @@ -21,7 +21,26 @@ message StreamLocationsResponse { common.SyncType sync_type = 2; } +message CreateLocationRequest { + string location = 1; +} +message CreateLocationResponse {} + +message UpdateLocationRequest { + string id = 1; + string location = 2; +} +message UpdateLocationResponse {} + +message DeleteLocationRequest { + string id = 1; +} +message DeleteLocationResponse {} + service LocationService { rpc GetLocations(GetLocationsRequest) returns (GetLocationsResponse); rpc StreamLocations(StreamLocationsRequest) returns (stream StreamLocationsResponse); + rpc CreateLocation(CreateLocationRequest) returns (CreateLocationResponse); + rpc UpdateLocation(UpdateLocationRequest) returns (UpdateLocationResponse); + rpc DeleteLocation(DeleteLocationRequest) returns (DeleteLocationResponse); } diff --git a/protos/api/session.proto b/protos/api/session.proto index 4d19ede..75e65ec 100644 --- a/protos/api/session.proto +++ b/protos/api/session.proto @@ -21,6 +21,27 @@ message StreamSessionsResponse { common.SyncType sync_type = 2; } +message CreateSessionRequest { + common.Timestamp start_time = 1; + common.Timestamp end_time = 2; + string location_id = 3; +} +message CreateSessionResponse {} + +message UpdateSessionRequest { + string id = 1; + common.Timestamp start_time = 2; + common.Timestamp end_time = 3; + string location_id = 4; + bool finished = 5; +} +message UpdateSessionResponse {} + +message DeleteSessionRequest { + string id = 1; +} +message DeleteSessionResponse {} + message CheckInOutRequest { string team_member_id = 1; db.Location location = 2; @@ -33,5 +54,8 @@ message CheckInOutResponse { service SessionService { rpc GetSessions(GetSessionsRequest) returns (GetSessionsResponse); rpc StreamSessions(StreamSessionsRequest) returns (stream StreamSessionsResponse); + rpc CreateSession(CreateSessionRequest) returns (CreateSessionResponse); + rpc UpdateSession(UpdateSessionRequest) returns (UpdateSessionResponse); + rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); rpc CheckInOut(CheckInOutRequest) returns (CheckInOutResponse); } diff --git a/protos/api/settings.proto b/protos/api/settings.proto index 1f72b3b..5b4eefc 100644 --- a/protos/api/settings.proto +++ b/protos/api/settings.proto @@ -14,7 +14,11 @@ message UpdateSettingsRequest { } message UpdateSettingsResponse {} +message PurgeDatabaseRequest {} +message PurgeDatabaseResponse {} + service SettingsService { rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse); rpc UpdateSettings(UpdateSettingsRequest) returns (UpdateSettingsResponse); + rpc PurgeDatabase(PurgeDatabaseRequest) returns (PurgeDatabaseResponse); } diff --git a/protos/api/stats.proto b/protos/api/stats.proto new file mode 100644 index 0000000..acbe256 --- /dev/null +++ b/protos/api/stats.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package tk.api; + +import "db/db.proto"; + +message HoursBucket { + double regular_secs = 1; + double overtime_secs = 2; +} + +message LeaderboardEntry { + string team_member_id = 1; + db.TeamMember team_member = 2; + HoursBucket active_session = 3; + HoursBucket this_week = 4; + HoursBucket all_time = 5; + double total_secs = 6; +} + +message GetLeaderboardRequest {} +message GetLeaderboardResponse { + repeated LeaderboardEntry entries = 1; +} + +service StatsService { + rpc GetLeaderboard(GetLeaderboardRequest) returns (GetLeaderboardResponse); +} diff --git a/server/Cargo.toml b/server/Cargo.toml index dd77970..172ed7a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,8 +8,10 @@ anyhow = "1.0.101" async-stream = "0.3.6" axum = "0.8.8" chrono = "0.4.43" +chrono-tz = "0.10" clap = { version = "4.5.57", features = ["derive"] } dashmap = "6.1.0" +icalendar = { version = "0.17", features = ["parser"] } jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } log = "0.4.29" log4rs = "1.4.0" diff --git a/server/src/core/api.rs b/server/src/core/api.rs index ed64c24..d81e76e 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, team_member_service_server::TeamMemberServiceServer, - user_service_server::UserServiceServer, + settings_service_server::SettingsServiceServer, stats_service_server::StatsServiceServer, + team_member_service_server::TeamMemberServiceServer, user_service_server::UserServiceServer, }, modules::{ health::HealthApi, location::LocationApi, schedule::ScheduleApi, session::SessionApi, settings::SettingsApi, - team_member::TeamMemberApi, user::UserApi, + stats::StatsApi, team_member::TeamMemberApi, user::UserApi, }, }; @@ -56,7 +56,8 @@ impl Api { .add_service(TeamMemberServiceServer::with_interceptor(TeamMemberApi {}, auth_interceptor)) .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(LocationServiceServer::with_interceptor(LocationApi {}, auth_interceptor)) + .add_service(StatsServiceServer::with_interceptor(StatsApi {}, 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 82d2fd3..a52d428 100644 --- a/server/src/generated/tk.api.rs +++ b/server/src/generated/tk.api.rs @@ -22,6 +22,29 @@ pub struct StreamLocationsResponse { #[prost(enumeration = "super::common::SyncType", tag = "2")] pub sync_type: i32, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateLocationRequest { + #[prost(string, tag = "1")] + pub location: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateLocationResponse {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateLocationRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub location: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateLocationResponse {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteLocationRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteLocationResponse {} /// Generated client implementations. pub mod location_service_client { #![allow( @@ -161,6 +184,78 @@ pub mod location_service_client { .insert(GrpcMethod::new("tk.api.LocationService", "StreamLocations")); self.inner.server_streaming(req, path, codec).await } + pub async fn create_location( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.LocationService/CreateLocation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.LocationService", "CreateLocation")); + self.inner.unary(req, path, codec).await + } + pub async fn update_location( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.LocationService/UpdateLocation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.LocationService", "UpdateLocation")); + self.inner.unary(req, path, codec).await + } + pub async fn delete_location( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.LocationService/DeleteLocation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.LocationService", "DeleteLocation")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -196,6 +291,27 @@ pub mod location_service_server { tonic::Response, tonic::Status, >; + async fn create_location( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn update_location( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_location( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct LocationServiceServer { @@ -366,6 +482,144 @@ pub mod location_service_server { }; Box::pin(fut) } + "/tk.api.LocationService/CreateLocation" => { + #[allow(non_camel_case_types)] + struct CreateLocationSvc(pub Arc); + impl< + T: LocationService, + > tonic::server::UnaryService + for CreateLocationSvc { + type Response = super::CreateLocationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::create_location(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CreateLocationSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/tk.api.LocationService/UpdateLocation" => { + #[allow(non_camel_case_types)] + struct UpdateLocationSvc(pub Arc); + impl< + T: LocationService, + > tonic::server::UnaryService + for UpdateLocationSvc { + type Response = super::UpdateLocationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::update_location(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpdateLocationSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/tk.api.LocationService/DeleteLocation" => { + #[allow(non_camel_case_types)] + struct DeleteLocationSvc(pub Arc); + impl< + T: LocationService, + > tonic::server::UnaryService + for DeleteLocationSvc { + type Response = super::DeleteLocationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_location(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeleteLocationSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( @@ -821,6 +1075,39 @@ pub struct StreamSessionsResponse { pub sync_type: i32, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateSessionRequest { + #[prost(message, optional, tag = "1")] + pub start_time: ::core::option::Option, + #[prost(message, optional, tag = "2")] + pub end_time: ::core::option::Option, + #[prost(string, tag = "3")] + pub location_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateSessionResponse {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateSessionRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub start_time: ::core::option::Option, + #[prost(message, optional, tag = "3")] + pub end_time: ::core::option::Option, + #[prost(string, tag = "4")] + pub location_id: ::prost::alloc::string::String, + #[prost(bool, tag = "5")] + pub finished: bool, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateSessionResponse {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteSessionRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteSessionResponse {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct CheckInOutRequest { #[prost(string, tag = "1")] pub team_member_id: ::prost::alloc::string::String, @@ -971,11 +1258,11 @@ pub mod session_service_client { .insert(GrpcMethod::new("tk.api.SessionService", "StreamSessions")); self.inner.server_streaming(req, path, codec).await } - pub async fn check_in_out( + pub async fn create_session( &mut self, - request: impl tonic::IntoRequest, + request: impl tonic::IntoRequest, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, > { self.inner @@ -988,50 +1275,143 @@ pub mod session_service_client { })?; let codec = tonic_prost::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/tk.api.SessionService/CheckInOut", + "/tk.api.SessionService/CreateSession", ); let mut req = request.into_request(); req.extensions_mut() - .insert(GrpcMethod::new("tk.api.SessionService", "CheckInOut")); + .insert(GrpcMethod::new("tk.api.SessionService", "CreateSession")); self.inner.unary(req, path, codec).await } - } -} -/// Generated server implementations. -pub mod session_service_server { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with SessionServiceServer. - #[async_trait] - pub trait SessionService: std::marker::Send + std::marker::Sync + 'static { - async fn get_sessions( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Server streaming response type for the StreamSessions method. - type StreamSessionsStream: tonic::codegen::tokio_stream::Stream< - Item = std::result::Result, - > - + std::marker::Send - + 'static; - async fn stream_sessions( - &self, - request: tonic::Request, + pub async fn update_session( + &mut self, + request: impl tonic::IntoRequest, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, - >; - async fn check_in_out( - &self, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.SessionService/UpdateSession", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.SessionService", "UpdateSession")); + self.inner.unary(req, path, codec).await + } + pub async fn delete_session( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.SessionService/DeleteSession", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.SessionService", "DeleteSession")); + self.inner.unary(req, path, codec).await + } + pub async fn check_in_out( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.SessionService/CheckInOut", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.SessionService", "CheckInOut")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod session_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with SessionServiceServer. + #[async_trait] + pub trait SessionService: std::marker::Send + std::marker::Sync + 'static { + async fn get_sessions( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Server streaming response type for the StreamSessions method. + type StreamSessionsStream: tonic::codegen::tokio_stream::Stream< + Item = std::result::Result, + > + + std::marker::Send + + 'static; + async fn stream_sessions( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn create_session( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn update_session( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_session( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn check_in_out( + &self, request: tonic::Request, ) -> std::result::Result< tonic::Response, @@ -1206,6 +1586,141 @@ pub mod session_service_server { }; Box::pin(fut) } + "/tk.api.SessionService/CreateSession" => { + #[allow(non_camel_case_types)] + struct CreateSessionSvc(pub Arc); + impl< + T: SessionService, + > tonic::server::UnaryService + for CreateSessionSvc { + type Response = super::CreateSessionResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::create_session(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CreateSessionSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/tk.api.SessionService/UpdateSession" => { + #[allow(non_camel_case_types)] + struct UpdateSessionSvc(pub Arc); + impl< + T: SessionService, + > tonic::server::UnaryService + for UpdateSessionSvc { + type Response = super::UpdateSessionResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::update_session(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpdateSessionSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/tk.api.SessionService/DeleteSession" => { + #[allow(non_camel_case_types)] + struct DeleteSessionSvc(pub Arc); + impl< + T: SessionService, + > tonic::server::UnaryService + for DeleteSessionSvc { + type Response = super::DeleteSessionResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_session(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeleteSessionSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/tk.api.SessionService/CheckInOut" => { #[allow(non_camel_case_types)] struct CheckInOutSvc(pub Arc); @@ -1305,6 +1820,10 @@ pub struct UpdateSettingsRequest { } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct UpdateSettingsResponse {} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PurgeDatabaseRequest {} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PurgeDatabaseResponse {} /// Generated client implementations. pub mod settings_service_client { #![allow( @@ -1444,6 +1963,30 @@ pub mod settings_service_client { .insert(GrpcMethod::new("tk.api.SettingsService", "UpdateSettings")); self.inner.unary(req, path, codec).await } + pub async fn purge_database( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.SettingsService/PurgeDatabase", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.SettingsService", "PurgeDatabase")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -1473,6 +2016,13 @@ pub mod settings_service_server { tonic::Response, tonic::Status, >; + async fn purge_database( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct SettingsServiceServer { @@ -1641,18 +2191,64 @@ pub mod settings_service_server { }; Box::pin(fut) } - _ => { - Box::pin(async move { - let mut response = http::Response::new( - tonic::body::Body::default(), - ); - let headers = response.headers_mut(); - headers - .insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers + "/tk.api.SettingsService/PurgeDatabase" => { + #[allow(non_camel_case_types)] + struct PurgeDatabaseSvc(pub Arc); + impl< + T: SettingsService, + > tonic::server::UnaryService + for PurgeDatabaseSvc { + type Response = super::PurgeDatabaseResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::purge_database(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PurgeDatabaseSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers .insert( http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE, @@ -1681,6 +2277,334 @@ pub mod settings_service_server { const NAME: &'static str = SERVICE_NAME; } } +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct HoursBucket { + #[prost(double, tag = "1")] + pub regular_secs: f64, + #[prost(double, tag = "2")] + pub overtime_secs: f64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LeaderboardEntry { + #[prost(string, tag = "1")] + pub team_member_id: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub team_member: ::core::option::Option, + #[prost(message, optional, tag = "3")] + pub active_session: ::core::option::Option, + #[prost(message, optional, tag = "4")] + pub this_week: ::core::option::Option, + #[prost(message, optional, tag = "5")] + pub all_time: ::core::option::Option, + #[prost(double, tag = "6")] + pub total_secs: f64, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetLeaderboardRequest {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetLeaderboardResponse { + #[prost(message, repeated, tag = "1")] + pub entries: ::prost::alloc::vec::Vec, +} +/// Generated client implementations. +pub mod stats_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct StatsServiceClient { + inner: tonic::client::Grpc, + } + impl StatsServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl StatsServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> StatsServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + StatsServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn get_leaderboard( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/tk.api.StatsService/GetLeaderboard", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("tk.api.StatsService", "GetLeaderboard")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod stats_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with StatsServiceServer. + #[async_trait] + pub trait StatsService: std::marker::Send + std::marker::Sync + 'static { + async fn get_leaderboard( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct StatsServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl StatsServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for StatsServiceServer + where + T: StatsService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/tk.api.StatsService/GetLeaderboard" => { + #[allow(non_camel_case_types)] + struct GetLeaderboardSvc(pub Arc); + impl< + T: StatsService, + > tonic::server::UnaryService + for GetLeaderboardSvc { + type Response = super::GetLeaderboardResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_leaderboard(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetLeaderboardSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for StatsServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "tk.api.StatsService"; + impl tonic::server::NamedService for StatsServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct UploadStudentCsvRequest { #[prost(bytes = "vec", tag = "1")] diff --git a/server/src/modules/location/api.rs b/server/src/modules/location/api.rs index 18a446f..8d8fcbe 100644 --- a/server/src/modules/location/api.rs +++ b/server/src/modules/location/api.rs @@ -7,16 +7,18 @@ use tokio_stream::{ use tonic::{Request, Response, Result, Status}; use crate::{ + auth::auth_helpers::require_permission, core::{ events::{ChangeEvent, EVENT_BUS}, shutdown::with_shutdown, }, generated::{ api::{ + CreateLocationRequest, CreateLocationResponse, DeleteLocationRequest, DeleteLocationResponse, GetLocationsRequest, GetLocationsResponse, LocationResponse, StreamLocationsRequest, StreamLocationsResponse, - location_service_server::LocationService, + UpdateLocationRequest, UpdateLocationResponse, location_service_server::LocationService, }, - common::SyncType, + common::{Role, SyncType}, db::Location, }, modules::location::LocationRepository, @@ -60,12 +62,10 @@ impl LocationService for LocationApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.map(|location| { - Ok(StreamLocationsResponse { - locations: vec![LocationResponse { id, location: Some(location) }], - sync_type: SyncType::Partial as i32, - }) - }), + ChangeEvent::Record { id, data, .. } => Some(Ok(StreamLocationsResponse { + locations: vec![LocationResponse { id, location: data }], + sync_type: SyncType::Partial as i32, + })), ChangeEvent::Table => match get_all_locations() { Ok(locations) => Some(Ok(StreamLocationsResponse { locations, sync_type: SyncType::Full as i32 })), Err(e) => { @@ -88,4 +88,69 @@ impl LocationService for LocationApi { Ok(Response::new(Box::pin(full_stream))) } + + async fn create_location( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + if request.location.is_empty() { + return Err(Status::invalid_argument("Location name is required")); + } + + let location = Location { location: request.location }; + Location::add(&location).map_err(|e| Status::internal(format!("Failed to create location: {}", e)))?; + + Ok(Response::new(CreateLocationResponse {})) + } + + async fn update_location( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + if request.id.is_empty() { + return Err(Status::invalid_argument("Location ID is required")); + } + + let existing = + Location::get(&request.id).map_err(|e| Status::internal(format!("Failed to get location: {}", e)))?; + + if existing.is_none() { + return Err(Status::not_found("Location not found")); + } + + let location = Location { location: request.location }; + Location::update(&request.id, &location) + .map_err(|e| Status::internal(format!("Failed to update location: {}", e)))?; + + Ok(Response::new(UpdateLocationResponse {})) + } + + async fn delete_location( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + if request.id.is_empty() { + return Err(Status::invalid_argument("Location ID is required")); + } + + let existing = + Location::get(&request.id).map_err(|e| Status::internal(format!("Failed to get location: {}", e)))?; + + if existing.is_none() { + return Err(Status::not_found("Location not found")); + } + + Location::remove(&request.id).map_err(|e| Status::internal(format!("Failed to delete location: {}", e)))?; + + Ok(Response::new(DeleteLocationResponse {})) + } } diff --git a/server/src/modules/mod.rs b/server/src/modules/mod.rs index 48188d4..ff50ef9 100644 --- a/server/src/modules/mod.rs +++ b/server/src/modules/mod.rs @@ -4,5 +4,6 @@ pub mod schedule; pub mod secret; pub mod session; pub mod settings; +pub mod stats; pub mod team_member; pub mod user; diff --git a/server/src/modules/schedule/api.rs b/server/src/modules/schedule/api.rs index bff2aac..7f5724b 100644 --- a/server/src/modules/schedule/api.rs +++ b/server/src/modules/schedule/api.rs @@ -92,6 +92,64 @@ impl ScheduleService for ScheduleApi { request: Request, ) -> Result, Status> { require_permission(&request, Role::Admin)?; + let bytes = request.into_inner().ics_data; + let ics_string = String::from_utf8_lossy(&bytes); + let schedule = match Schedule::from_ics(&ics_string) { + Ok(schedule) => schedule, + Err(err) => return Err(Status::invalid_argument(err.to_string())), + }; + + for location in schedule.locations { + let existing_loc = match Location::get_by_name(&location) { + Ok(existing_loc) => existing_loc, + Err(err) => { + log::error!("Database error getting data {}: {}", location, err); + return Err(Status::not_found(err.to_string())); + } + }; + + if existing_loc.is_empty() { + match Location::add(&Location { location: location.clone() }) { + Ok(_) => {} + Err(err) => { + log::error!("Database error adding location {}: {}", location, err); + return Err(Status::internal(err.to_string())); + } + } + } + } + + for session in schedule.sessions { + let locations = match Location::get_by_name(&session.location_name) { + Ok(locations) => locations, + Err(err) => { + log::error!("Database error getting location {}: {}", session.location_name, err); + return Err(Status::internal(err.to_string())); + } + }; + + let Some((location_id, _)) = locations.into_iter().next() else { + log::error!("Location not found: {}", session.location_name); + return Err(Status::not_found(format!("Location not found: {}", session.location_name))); + }; + + let new_session = Session { + start_time: Some(session.start_time), + end_time: Some(session.end_time), + location_id, + member_sessions: vec![], + finished: false, + }; + + match Session::add(&new_session) { + Ok(_) => {} + Err(err) => { + log::error!("Database error adding session: {}", err); + return Err(Status::internal(err.to_string())); + } + } + } + Ok(Response::new(UploadScheduleIcsResponse {})) } } diff --git a/server/src/modules/schedule/ics_parser.rs b/server/src/modules/schedule/ics_parser.rs new file mode 100644 index 0000000..0e63bda --- /dev/null +++ b/server/src/modules/schedule/ics_parser.rs @@ -0,0 +1,116 @@ +use anyhow::{Result, anyhow, bail}; +use chrono::{NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use icalendar::{Calendar, CalendarComponent, CalendarDateTime, Component, DatePerhapsTime, EventLike}; + +use crate::{ + generated::common::Timestamp, + modules::schedule::{Schedule, ScheduleSessionT}, +}; + +/// Try to parse a timezone string (e.g. "Australia/Perth") into a chrono-tz Tz. +fn parse_tz(tzid: &str) -> Option { + tzid.parse::().ok() +} + +fn date_perhaps_time_to_timestamp(dpt: &DatePerhapsTime, default_tz: Option) -> Result { + match dpt { + DatePerhapsTime::DateTime(cal_dt) => calendar_datetime_to_timestamp(cal_dt, default_tz), + DatePerhapsTime::Date(date) => naive_date_to_timestamp(*date, default_tz), + } +} + +fn calendar_datetime_to_timestamp(cal_dt: &CalendarDateTime, default_tz: Option) -> Result { + let seconds = match cal_dt { + CalendarDateTime::Utc(dt) => dt.timestamp(), + CalendarDateTime::Floating(naive) => { + if let Some(tz) = default_tz { + tz.from_local_datetime(naive) + .single() + .ok_or_else(|| anyhow!("Ambiguous or invalid local time: {} in {}", naive, tz))? + .with_timezone(&Utc) + .timestamp() + } else { + naive.and_utc().timestamp() + } + } + CalendarDateTime::WithTimezone { date_time, tzid } => { + if let Some(tz) = parse_tz(tzid) { + tz.from_local_datetime(date_time) + .single() + .ok_or_else(|| anyhow!("Ambiguous or invalid local time: {} in {}", date_time, tz))? + .with_timezone(&Utc) + .timestamp() + } else { + log::warn!("Unknown timezone '{}', treating as UTC", tzid); + date_time.and_utc().timestamp() + } + } + }; + Ok(Timestamp { seconds, nanos: 0 }) +} + +fn naive_date_to_timestamp(date: NaiveDate, default_tz: Option) -> Result { + let naive = date.and_hms_opt(0, 0, 0).ok_or_else(|| anyhow!("Could not create datetime from date: {}", date))?; + + let seconds = if let Some(tz) = default_tz { + tz.from_local_datetime(&naive) + .single() + .ok_or_else(|| anyhow!("Ambiguous or invalid local time: {} in {}", naive, tz))? + .with_timezone(&Utc) + .timestamp() + } else { + naive.and_utc().timestamp() + }; + + Ok(Timestamp { seconds, nanos: 0 }) +} + +pub struct IcsParser; + +impl IcsParser { + pub fn ics_to_schedule(ics: &str) -> Result { + let calendar: Calendar = ics.parse().map_err(|e: String| anyhow!(e))?; + + // Read the calendar-level timezone (X-WR-TIMEZONE) + let default_tz = calendar.property_value("X-WR-TIMEZONE").and_then(|tz_str| { + let tz = parse_tz(tz_str); + if tz.is_none() { + log::warn!("Unknown calendar timezone: {}", tz_str); + } + tz + }); + + let mut locations: Vec = Vec::new(); + let mut sessions: Vec = Vec::new(); + + for component in &calendar.components { + let CalendarComponent::Event(event) = component else { + continue; + }; + + let location = match event.get_location() { + Some(loc) => loc.to_string(), + None => continue, + }; + + let start = event.get_start().ok_or_else(|| anyhow!("Event missing DTSTART: {:?}", event.get_summary()))?; + let end = event.get_end().ok_or_else(|| anyhow!("Event missing DTEND: {:?}", event.get_summary()))?; + + let start_time = date_perhaps_time_to_timestamp(&start, default_tz)?; + let end_time = date_perhaps_time_to_timestamp(&end, default_tz)?; + + if !locations.contains(&location) { + locations.push(location.clone()); + } + + sessions.push(ScheduleSessionT { start_time, end_time, location_name: location }); + } + + if sessions.is_empty() { + bail!("No valid events found in ICS data"); + } + + Ok(Schedule { sessions, locations }) + } +} diff --git a/server/src/modules/schedule/mod.rs b/server/src/modules/schedule/mod.rs index 1cb7fbf..1ba2216 100644 --- a/server/src/modules/schedule/mod.rs +++ b/server/src/modules/schedule/mod.rs @@ -5,6 +5,7 @@ pub use api::*; use crate::{generated::common::Timestamp, modules::schedule::csv_parser::CsvParser}; mod csv_parser; +mod ics_parser; pub struct ScheduleSessionT { pub start_time: Timestamp, @@ -22,5 +23,7 @@ impl Schedule { CsvParser::csv_to_schedule(csv) } - // pub fn from_ics(ics: &str) -> Result {} + pub fn from_ics(ics: &str) -> Result { + ics_parser::IcsParser::ics_to_schedule(ics) + } } diff --git a/server/src/modules/session/api.rs b/server/src/modules/session/api.rs index b236ce4..8e4f9fb 100644 --- a/server/src/modules/session/api.rs +++ b/server/src/modules/session/api.rs @@ -14,8 +14,9 @@ use crate::{ }, generated::{ api::{ - CheckInOutRequest, CheckInOutResponse, GetSessionsRequest, GetSessionsResponse, SessionResponse, - StreamSessionsRequest, StreamSessionsResponse, session_service_server::SessionService, + CheckInOutRequest, CheckInOutResponse, CreateSessionRequest, CreateSessionResponse, DeleteSessionRequest, + DeleteSessionResponse, GetSessionsRequest, GetSessionsResponse, SessionResponse, StreamSessionsRequest, + StreamSessionsResponse, UpdateSessionRequest, UpdateSessionResponse, session_service_server::SessionService, }, common::{Role, SyncType}, db::{Session, TeamMemberSession}, @@ -68,12 +69,10 @@ impl SessionService for SessionApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.map(|session| { - Ok(StreamSessionsResponse { - sessions: vec![SessionResponse { id, session: Some(session) }], - sync_type: SyncType::Partial as i32, - }) - }), + ChangeEvent::Record { id, data, .. } => Some(Ok(StreamSessionsResponse { + sessions: vec![SessionResponse { id, session: data }], + sync_type: SyncType::Partial as i32, + })), ChangeEvent::Table => match get_all_sessions() { Ok(sessions) => Some(Ok(StreamSessionsResponse { sessions, sync_type: SyncType::Full as i32 })), Err(e) => { @@ -97,6 +96,80 @@ impl SessionService for SessionApi { Ok(Response::new(Box::pin(full_stream))) } + // Create / Update / Delete + + async fn create_session( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + let session = Session { + start_time: request.start_time, + end_time: request.end_time, + location_id: request.location_id, + member_sessions: vec![], + finished: false, + }; + + Session::add(&session).map_err(|e| Status::internal(format!("Failed to create session: {}", e)))?; + + Ok(Response::new(CreateSessionResponse {})) + } + + async fn update_session( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + if request.id.is_empty() { + return Err(Status::invalid_argument("Session ID is required")); + } + + let existing = Session::get(&request.id).map_err(|e| Status::internal(format!("Failed to get session: {}", e)))?; + + let Some(existing) = existing else { + return Err(Status::not_found("Session not found")); + }; + + let session = Session { + start_time: request.start_time, + end_time: request.end_time, + location_id: request.location_id, + member_sessions: existing.member_sessions, + finished: request.finished, + }; + + Session::update(&request.id, &session).map_err(|e| Status::internal(format!("Failed to update session: {}", e)))?; + + Ok(Response::new(UpdateSessionResponse {})) + } + + async fn delete_session( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + let request = request.into_inner(); + + if request.id.is_empty() { + return Err(Status::invalid_argument("Session ID is required")); + } + + let existing = Session::get(&request.id).map_err(|e| Status::internal(format!("Failed to get session: {}", e)))?; + + if existing.is_none() { + return Err(Status::not_found("Session not found")); + } + + Session::remove(&request.id).map_err(|e| Status::internal(format!("Failed to delete session: {}", e)))?; + + Ok(Response::new(DeleteSessionResponse {})) + } + // Check In / Out async fn check_in_out(&self, request: Request) -> Result, Status> { diff --git a/server/src/modules/settings/api.rs b/server/src/modules/settings/api.rs index 6205d60..55a0a5e 100644 --- a/server/src/modules/settings/api.rs +++ b/server/src/modules/settings/api.rs @@ -2,10 +2,11 @@ use tonic::{Request, Response, Result, Status}; use crate::{ auth::auth_helpers::require_permission, + core::db::{get_db, recreate_default_admin}, generated::{ api::{ - GetSettingsRequest, GetSettingsResponse, UpdateSettingsRequest, UpdateSettingsResponse, - settings_service_server::SettingsService, + GetSettingsRequest, GetSettingsResponse, PurgeDatabaseRequest, PurgeDatabaseResponse, UpdateSettingsRequest, + UpdateSettingsResponse, settings_service_server::SettingsService, }, common::Role, db::Settings, @@ -34,4 +35,20 @@ impl SettingsService for SettingsApi { Ok(Response::new(UpdateSettingsResponse {})) } + + async fn purge_database( + &self, + request: Request, + ) -> Result, Status> { + require_permission(&request, Role::Admin)?; + + let db = get_db().map_err(|e| Status::internal(format!("Failed to get database: {}", e)))?; + db.clear().map_err(|e| Status::internal(format!("Failed to purge database: {}", e)))?; + + log::warn!("Database purged by admin"); + + recreate_default_admin().map_err(|e| Status::internal(format!("Failed to recreate admin user: {}", e)))?; + + Ok(Response::new(PurgeDatabaseResponse {})) + } } diff --git a/server/src/modules/stats/api.rs b/server/src/modules/stats/api.rs new file mode 100644 index 0000000..a99def7 --- /dev/null +++ b/server/src/modules/stats/api.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; + +use chrono::{Datelike, Utc}; +use tonic::{Request, Response, Result, Status}; + +use crate::{ + generated::{ + api::{ + GetLeaderboardRequest, GetLeaderboardResponse, HoursBucket, LeaderboardEntry, stats_service_server::StatsService, + }, + db::{Session, TeamMember, TeamMemberSession}, + }, + modules::{session::SessionRepository, team_member::TeamMemberRepository}, +}; + +pub struct StatsApi; + +/// Compute regular and overtime seconds for a single member session. +/// +/// Regular = time within [session_start, session_end]. +/// Overtime = time outside that window (before start or after end). +fn compute_hours(ms: &TeamMemberSession, session: &Session, now_secs: i64) -> (f64, f64) { + let check_in = match &ms.check_in_time { + Some(t) => t.seconds, + None => return (0.0, 0.0), + }; + + let check_out = match &ms.check_out_time { + Some(t) => t.seconds, + None => now_secs, + }; + + if check_out <= check_in { + return (0.0, 0.0); + } + + let session_start = session.start_time.as_ref().map_or(0, |t| t.seconds); + let session_end = session.end_time.as_ref().map_or(0, |t| t.seconds); + + // Regular = overlap of [check_in, check_out] with [session_start, session_end] + let overlap_start = check_in.max(session_start); + let overlap_end = check_out.min(session_end); + + #[allow(clippy::cast_precision_loss)] + let regular_secs = (overlap_end - overlap_start).max(0) as f64; + + #[allow(clippy::cast_precision_loss)] + let total_secs = (check_out - check_in) as f64; + let overtime_secs = total_secs - regular_secs; + + (regular_secs, overtime_secs) +} + +/// Get the Unix timestamp (seconds) for Monday 00:00 UTC of the current week. +fn week_start_secs() -> i64 { + let now = Utc::now(); + let days_since_monday = i64::from(now.weekday().num_days_from_monday()); + let monday = now.date_naive() - chrono::Duration::days(days_since_monday); + monday.and_hms_opt(0, 0, 0).map_or(0, |dt| dt.and_utc().timestamp()) +} + +struct MemberAccumulator { + active_session: HoursBucket, + this_week: HoursBucket, + all_time: HoursBucket, +} + +impl MemberAccumulator { + fn new() -> Self { + Self { + active_session: HoursBucket { regular_secs: 0.0, overtime_secs: 0.0 }, + this_week: HoursBucket { regular_secs: 0.0, overtime_secs: 0.0 }, + all_time: HoursBucket { regular_secs: 0.0, overtime_secs: 0.0 }, + } + } + + fn add(&mut self, regular: f64, overtime: f64, is_active: bool, is_this_week: bool) { + self.all_time.regular_secs += regular; + self.all_time.overtime_secs += overtime; + + if is_this_week { + self.this_week.regular_secs += regular; + self.this_week.overtime_secs += overtime; + } + + if is_active { + self.active_session.regular_secs += regular; + self.active_session.overtime_secs += overtime; + } + } +} + +#[tonic::async_trait] +impl StatsService for StatsApi { + async fn get_leaderboard( + &self, + _request: Request, + ) -> Result, Status> { + let sessions = Session::get_all().map_err(|e| Status::internal(format!("Failed to get sessions: {}", e)))?; + let team_members = + TeamMember::get_all().map_err(|e| Status::internal(format!("Failed to get team members: {}", e)))?; + + let now_secs = Utc::now().timestamp(); + let week_start = week_start_secs(); + let week_end = week_start + 7 * 24 * 60 * 60; + + let mut accumulators: HashMap = HashMap::new(); + + for session in sessions.values() { + if session.start_time.is_none() || session.end_time.is_none() { + continue; + } + + let session_start_secs = session.start_time.as_ref().map_or(0, |t| t.seconds); + let is_active = !session.finished; + let is_this_week = session_start_secs >= week_start && session_start_secs < week_end; + + for ms in &session.member_sessions { + if ms.check_in_time.is_none() { + continue; + } + + let (regular, overtime) = compute_hours(ms, session, now_secs); + + accumulators.entry(ms.team_member_id.clone()).or_insert_with(MemberAccumulator::new).add( + regular, + overtime, + is_active, + is_this_week, + ); + } + } + + let mut entries: Vec = accumulators + .into_iter() + .filter_map(|(member_id, acc)| { + let member = team_members.get(&member_id)?; + let total_secs = acc.all_time.regular_secs + acc.all_time.overtime_secs; + + Some(LeaderboardEntry { + team_member_id: member_id, + team_member: Some(member.clone()), + active_session: Some(acc.active_session), + this_week: Some(acc.this_week), + all_time: Some(acc.all_time), + total_secs, + }) + }) + .collect(); + + entries.sort_by(|a, b| b.total_secs.partial_cmp(&a.total_secs).unwrap_or(std::cmp::Ordering::Equal)); + + Ok(Response::new(GetLeaderboardResponse { entries })) + } +} diff --git a/server/src/modules/stats/mod.rs b/server/src/modules/stats/mod.rs new file mode 100644 index 0000000..51dd1e3 --- /dev/null +++ b/server/src/modules/stats/mod.rs @@ -0,0 +1,2 @@ +mod api; +pub use api::*; diff --git a/server/src/modules/team_member/api.rs b/server/src/modules/team_member/api.rs index 4dbade2..6b8ba9a 100644 --- a/server/src/modules/team_member/api.rs +++ b/server/src/modules/team_member/api.rs @@ -177,12 +177,10 @@ impl TeamMemberService for TeamMemberApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.map(|member| { - Ok(StreamTeamMembersResponse { - team_members: vec![TeamMemberResponse { id, team_member: Some(member) }], - sync_type: SyncType::Partial as i32, - }) - }), + ChangeEvent::Record { id, data, .. } => Some(Ok(StreamTeamMembersResponse { + team_members: vec![TeamMemberResponse { id, team_member: data }], + sync_type: SyncType::Partial as i32, + })), ChangeEvent::Table => match get_all_members() { Ok(members) => { Some(Ok(StreamTeamMembersResponse { team_members: members, sync_type: SyncType::Full as i32 })) @@ -226,16 +224,17 @@ impl TeamMemberService for TeamMemberApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.and_then(|member| { - if member.member_type == TeamMemberType::Student as i32 { - Some(Ok(StreamStudentsResponse { - students: vec![TeamMemberResponse { id, team_member: Some(member) }], - sync_type: SyncType::Partial as i32, - })) - } else { - None - } - }), + ChangeEvent::Record { id, data, .. } => match data { + Some(member) if member.member_type == TeamMemberType::Student as i32 => Some(Ok(StreamStudentsResponse { + students: vec![TeamMemberResponse { id, team_member: Some(member) }], + sync_type: SyncType::Partial as i32, + })), + None => Some(Ok(StreamStudentsResponse { + students: vec![TeamMemberResponse { id, team_member: None }], + sync_type: SyncType::Partial as i32, + })), + _ => None, + }, ChangeEvent::Table => match get_all_members() { Ok(members) => { let students = filter_by_type(members, TeamMemberType::Student); @@ -280,16 +279,17 @@ impl TeamMemberService for TeamMemberApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.and_then(|member| { - if member.member_type == TeamMemberType::Mentor as i32 { - Some(Ok(StreamMentorsResponse { - mentors: vec![TeamMemberResponse { id, team_member: Some(member) }], - sync_type: SyncType::Partial as i32, - })) - } else { - None - } - }), + ChangeEvent::Record { id, data, .. } => match data { + Some(member) if member.member_type == TeamMemberType::Mentor as i32 => Some(Ok(StreamMentorsResponse { + mentors: vec![TeamMemberResponse { id, team_member: Some(member) }], + sync_type: SyncType::Partial as i32, + })), + None => Some(Ok(StreamMentorsResponse { + mentors: vec![TeamMemberResponse { id, team_member: None }], + sync_type: SyncType::Partial as i32, + })), + _ => None, + }, ChangeEvent::Table => match get_all_members() { Ok(members) => { let mentors = filter_by_type(members, TeamMemberType::Mentor); diff --git a/server/src/modules/user/api.rs b/server/src/modules/user/api.rs index 39a68ca..8319801 100644 --- a/server/src/modules/user/api.rs +++ b/server/src/modules/user/api.rs @@ -120,15 +120,17 @@ impl UserService for UserApi { let stream = BroadcastStream::new(rx).filter_map(|result| match result { Ok(event) => match event { - ChangeEvent::Record { id, data, .. } => data.and_then(|user| { - if user.username == DEFAULT_ADMIN_USERNAME { - return None; - } - Some(Ok(StreamUsersResponse { + ChangeEvent::Record { id, data, .. } => match data { + Some(user) if user.username == DEFAULT_ADMIN_USERNAME => None, + Some(user) => Some(Ok(StreamUsersResponse { users: vec![UserResponse { id, username: user.username, roles: user.roles }], sync_type: SyncType::Partial as i32, - })) - }), + })), + None => Some(Ok(StreamUsersResponse { + users: vec![UserResponse { id, username: String::new(), roles: vec![] }], + sync_type: SyncType::Partial as i32, + })), + }, ChangeEvent::Table => match get_all_users() { Ok(users) => Some(Ok(StreamUsersResponse { users, sync_type: SyncType::Full as i32 })), Err(e) => { diff --git a/test_data/schedule.ics b/test_data/schedule.ics new file mode 100644 index 0000000..744bfb6 --- /dev/null +++ b/test_data/schedule.ics @@ -0,0 +1,143 @@ +BEGIN:VCALENDAR +PRODID:-//TimeKeeper//Test Data//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:FRC 2026 Build Season +X-WR-TIMEZONE:Australia/Perth +BEGIN:VEVENT +DTSTART:20260210T160000 +DTEND:20260210T200000 +DTSTAMP:20260210T000000Z +UID:session-001@timekeeper +SUMMARY:Workshop Session +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260211T160000 +DTEND:20260211T200000 +DTSTAMP:20260210T000000Z +UID:session-002@timekeeper +SUMMARY:Workshop Session +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260212T160000 +DTEND:20260212T200000 +DTSTAMP:20260210T000000Z +UID:session-003@timekeeper +SUMMARY:Workshop Session +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260213T160000 +DTEND:20260213T200000 +DTSTAMP:20260210T000000Z +UID:session-004@timekeeper +SUMMARY:Workshop Session +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260214T160000 +DTEND:20260214T200000 +DTSTAMP:20260210T000000Z +UID:session-005@timekeeper +SUMMARY:Workshop Session +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260215T100000 +DTEND:20260215T160000 +DTSTAMP:20260210T000000Z +UID:session-006@timekeeper +SUMMARY:CAD Design Review +LOCATION:CAD Room +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260216T160000 +DTEND:20260216T200000 +DTSTAMP:20260210T000000Z +UID:session-007@timekeeper +SUMMARY:Electrical Wiring +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260217T160000 +DTEND:20260217T190000 +DTSTAMP:20260210T000000Z +UID:session-008@timekeeper +SUMMARY:Programming Sprint +LOCATION:CAD Room +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260218T160000 +DTEND:20260218T200000 +DTSTAMP:20260210T000000Z +UID:session-009@timekeeper +SUMMARY:Mechanical Assembly +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260219T160000 +DTEND:20260219T200000 +DTSTAMP:20260210T000000Z +UID:session-010@timekeeper +SUMMARY:Drive Practice +LOCATION:Practice Field +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260220T160000 +DTEND:20260220T200000 +DTSTAMP:20260210T000000Z +UID:session-011@timekeeper +SUMMARY:Strategy Meeting +LOCATION:Meeting Room +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260221T100000 +DTEND:20260221T170000 +DTSTAMP:20260210T000000Z +UID:session-012@timekeeper +SUMMARY:Full Robot Integration +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260222T100000 +DTEND:20260222T160000 +DTSTAMP:20260210T000000Z +UID:session-013@timekeeper +SUMMARY:Drive Practice & Autonomous Testing +LOCATION:Practice Field +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260223T160000 +DTEND:20260223T190000 +DTSTAMP:20260210T000000Z +UID:session-014@timekeeper +SUMMARY:CAD Final Drawings +LOCATION:CAD Room +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +DTSTART:20260224T160000 +DTEND:20260224T200000 +DTSTAMP:20260210T000000Z +UID:session-015@timekeeper +SUMMARY:Robot Inspection Prep +LOCATION:Workshop +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/vars.yml b/vars.yml index 9b133b6..75cb7ec 100644 --- a/vars.yml +++ b/vars.yml @@ -3,5 +3,3 @@ variables: - name: tk_version value: 1.0.0 - - name: flutter_version - value: 3.38.9