Skip to content

Commit ce6c3e8

Browse files
committed
Updated rfid check in/out look
1 parent 9e8c796 commit ce6c3e8

File tree

7 files changed

+677
-239
lines changed

7 files changed

+677
-239
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:hooks_riverpod/hooks_riverpod.dart';
3+
import 'package:logger/logger.dart';
4+
import 'package:time_keeper/generated/api/api.pbgrpc.dart';
5+
import 'package:time_keeper/generated/db/db.pb.dart';
6+
import 'package:time_keeper/helpers/grpc_call_wrapper.dart';
7+
import 'package:time_keeper/providers/location_provider.dart';
8+
import 'package:time_keeper/providers/session_provider.dart';
9+
import 'package:time_keeper/providers/team_member_provider.dart';
10+
import 'package:time_keeper/utils/formatting.dart';
11+
import 'package:time_keeper/utils/grpc_result.dart';
12+
import 'package:time_keeper/widgets/dialogs/toast_overlay.dart';
13+
14+
final _log = Logger();
15+
16+
/// Handles an RFID scan input by matching it against team members
17+
/// and checking them in/out.
18+
Future<void> handleKioskScan({
19+
required String input,
20+
required BuildContext context,
21+
required WidgetRef ref,
22+
}) async {
23+
final trimmed = input.trim().toLowerCase();
24+
if (trimmed.isEmpty) return;
25+
26+
final teamMembers = ref.read(teamMembersProvider);
27+
final match = _findMember(trimmed, teamMembers);
28+
29+
if (match == null) {
30+
_log.w('No member matched scan: $input');
31+
if (context.mounted) {
32+
ToastOverlay.error(
33+
context,
34+
title: 'Unrecognized',
35+
message: 'Unrecognized value "$input", contact admin.',
36+
);
37+
}
38+
return;
39+
}
40+
41+
final memberId = match.key;
42+
final member = match.value;
43+
final alias = member.alias.isNotEmpty ? ' (${member.alias})' : '';
44+
final name = '${member.firstName} ${member.lastName}$alias';
45+
46+
_log.i('Scan matched member: $name');
47+
48+
final currentLocation = ref.read(currentLocationProvider) ?? '';
49+
final result = await callGrpcEndpoint(
50+
() => ref
51+
.read(sessionServiceProvider)
52+
.checkInOut(
53+
CheckInOutRequest(
54+
teamMemberId: memberId,
55+
location: Location(location: currentLocation),
56+
),
57+
),
58+
);
59+
60+
if (!context.mounted) return;
61+
62+
final now = DateTime.now();
63+
final timeStr = formatTime(now);
64+
65+
switch (result) {
66+
case GrpcSuccess(data: final response):
67+
if (response.checkedIn) {
68+
ToastOverlay.success(
69+
context,
70+
title: 'Checked In',
71+
message: '$name\n$timeStr',
72+
);
73+
} else {
74+
ToastOverlay.warn(
75+
context,
76+
title: 'Checked Out',
77+
message: '$name\n$timeStr',
78+
);
79+
}
80+
case GrpcFailure(userMessage: final msg):
81+
ToastOverlay.error(
82+
context,
83+
title: 'Check In Failed',
84+
message: 'Failed for $name: $msg',
85+
);
86+
}
87+
}
88+
89+
/// Tries to match scan input against team member fields.
90+
/// Checks in order: first+last name, alias, secondary alias.
91+
MapEntry<String, TeamMember>? _findMember(
92+
String input,
93+
Map<String, TeamMember> teamMembers,
94+
) {
95+
for (final entry in teamMembers.entries) {
96+
final member = entry.value;
97+
final fullName = '${member.firstName} ${member.lastName}'
98+
.trim()
99+
.toLowerCase();
100+
101+
if (fullName == input) return entry;
102+
}
103+
104+
for (final entry in teamMembers.entries) {
105+
final member = entry.value;
106+
107+
if (member.alias.isNotEmpty && member.alias.trim().toLowerCase() == input) {
108+
return entry;
109+
}
110+
111+
if (member.secondaryAlias.isNotEmpty &&
112+
member.secondaryAlias.trim().toLowerCase() == input) {
113+
return entry;
114+
}
115+
}
116+
117+
return null;
118+
}

client/lib/views/kiosk/kiosk_view.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:time_keeper/utils/rfid_scanner.dart';
1111
import 'package:time_keeper/utils/time.dart';
1212
import 'package:time_keeper/views/kiosk/checked_in_list.dart';
1313
import 'package:time_keeper/views/kiosk/kiosk_dialog.dart';
14+
import 'package:time_keeper/views/kiosk/kiosk_scan_handler.dart';
1415
import 'package:time_keeper/views/kiosk/session_info_bar.dart';
1516

1617
final _log = Logger();
@@ -44,13 +45,22 @@ class HomeView extends HookConsumerWidget {
4445

4546
// RFID keyboard listener - only active when user has KIOSK permission
4647
final scanBuffer = useRef<RfidScanBuffer?>(null);
48+
final contextRef = useRef<BuildContext?>(null);
49+
final refRef = useRef<WidgetRef?>(null);
50+
contextRef.value = context;
51+
refRef.value = ref;
4752

4853
useEffect(() {
4954
if (!hasKiosk) return null;
5055

5156
final buffer = RfidScanBuffer(
5257
onScan: (input) {
5358
_log.i('RFID scan input: $input');
59+
final ctx = contextRef.value;
60+
final r = refRef.value;
61+
if (ctx != null && ctx.mounted && r != null) {
62+
handleKioskScan(input: input, context: ctx, ref: r);
63+
}
5464
},
5565
);
5666
scanBuffer.value = buffer;

client/lib/views/locations/location_dialog.dart

Lines changed: 59 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
44
import 'package:time_keeper/generated/api/location.pbgrpc.dart';
55
import 'package:time_keeper/helpers/grpc_call_wrapper.dart';
66
import 'package:time_keeper/providers/location_provider.dart';
7+
import 'package:time_keeper/utils/grpc_result.dart';
78
import 'package:time_keeper/widgets/dialogs/confirm_dialog.dart';
89
import 'package:time_keeper/widgets/dialogs/popup_dialog.dart';
10+
import 'package:time_keeper/widgets/dialogs/snackbar_dialog.dart';
911

1012
void showLocationDialog(
1113
BuildContext context,
@@ -18,7 +20,6 @@ void showLocationDialog(
1820
PopupDialog.info(
1921
title: isEdit ? 'Edit Location' : 'Add Location',
2022
message: _LocationForm(
21-
ref: ref,
2223
isEdit: isEdit,
2324
locationId: id,
2425
initialName: existingName,
@@ -48,22 +49,21 @@ void showDeleteLocationDialog(
4849
).show(context);
4950
}
5051

51-
class _LocationForm extends HookWidget {
52-
final WidgetRef ref;
52+
class _LocationForm extends HookConsumerWidget {
5353
final bool isEdit;
5454
final String? locationId;
5555
final String? initialName;
5656

5757
const _LocationForm({
58-
required this.ref,
5958
required this.isEdit,
6059
this.locationId,
6160
this.initialName,
6261
});
6362

6463
@override
65-
Widget build(BuildContext context) {
64+
Widget build(BuildContext context, WidgetRef ref) {
6665
final nameController = useTextEditingController(text: initialName ?? '');
66+
final isLoading = useState(false);
6767

6868
return SizedBox(
6969
width: 400,
@@ -83,51 +83,66 @@ class _LocationForm extends HookWidget {
8383
mainAxisAlignment: MainAxisAlignment.end,
8484
children: [
8585
TextButton(
86-
onPressed: () => Navigator.of(context).pop(),
86+
onPressed: isLoading.value
87+
? null
88+
: () => Navigator.of(context).pop(),
8789
child: const Text('Cancel'),
8890
),
8991
const SizedBox(width: 8),
9092
FilledButton(
91-
onPressed: () {
92-
final name = nameController.text.trim();
93-
if (name.isEmpty) return;
93+
onPressed: isLoading.value
94+
? null
95+
: () async {
96+
final name = nameController.text.trim();
97+
if (name.isEmpty) return;
9498

95-
Navigator.of(context).pop();
99+
isLoading.value = true;
100+
try {
101+
final client = ref.read(locationServiceProvider);
102+
final GrpcResult<dynamic> result;
103+
if (isEdit) {
104+
result = await callGrpcEndpoint(
105+
() => client.updateLocation(
106+
UpdateLocationRequest(
107+
id: locationId,
108+
location: name,
109+
),
110+
),
111+
);
112+
} else {
113+
result = await callGrpcEndpoint(
114+
() => client.createLocation(
115+
CreateLocationRequest(location: name),
116+
),
117+
);
118+
}
96119

97-
ConfirmDialog.info(
98-
title: isEdit ? 'Update Location' : 'Create Location',
99-
message: isEdit
100-
? Text('Save changes to "$name"?')
101-
: Text('Create location "$name"?'),
102-
confirmText: isEdit ? 'Save' : 'Create',
103-
onConfirmAsyncGrpc: () async {
104-
final client = ref.read(locationServiceProvider);
105-
if (isEdit) {
106-
return await callGrpcEndpoint(
107-
() => client.updateLocation(
108-
UpdateLocationRequest(
109-
id: locationId,
110-
location: name,
111-
),
112-
),
113-
);
114-
} else {
115-
return await callGrpcEndpoint(
116-
() => client.createLocation(
117-
CreateLocationRequest(location: name),
118-
),
119-
);
120-
}
121-
},
122-
showResultDialog: true,
123-
successMessage: Text(
124-
isEdit
125-
? '"$name" updated successfully'
126-
: '"$name" created successfully',
127-
),
128-
).show(context);
129-
},
130-
child: Text(isEdit ? 'Save' : 'Create'),
120+
if (context.mounted) {
121+
Navigator.of(context).pop();
122+
switch (result) {
123+
case GrpcSuccess():
124+
SnackBarDialog.success(
125+
message: isEdit
126+
? '"$name" updated successfully'
127+
: '"$name" created successfully',
128+
).show(context);
129+
case GrpcFailure():
130+
SnackBarDialog.fromGrpcStatus(
131+
result: result,
132+
).show(context);
133+
}
134+
}
135+
} finally {
136+
isLoading.value = false;
137+
}
138+
},
139+
child: isLoading.value
140+
? const SizedBox(
141+
width: 16,
142+
height: 16,
143+
child: CircularProgressIndicator(strokeWidth: 2),
144+
)
145+
: Text(isEdit ? 'Save' : 'Create'),
131146
),
132147
],
133148
),

0 commit comments

Comments
 (0)