From 2834bb6214445ee7b6b4eee0bb664d04ff331f56 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Aug 2026 02:05:27 +0000 Subject: [PATCH] Sync v1.4.0 --- README.md | 2 +- firestore.rules | 3 +- lib/src/models/driver_schedule.dart | 460 +++++++++++ .../container_photo_sync_service.dart | 20 +- .../services/driver_schedule_generator.dart | 431 +++++++++++ lib/src/services/photo_service.dart | 1 + lib/src/state/pit_controller_mixin.dart | 20 +- lib/src/ui/app_shell.dart | 9 +- lib/src/ui/borrow_tab.dart | 2 + lib/src/ui/driver_schedule_screen.dart | 721 ++++++++++++++++++ lib/src/ui/packing_tab.dart | 80 +- lib/src/ui/schedule_tab.dart | 91 ++- pubspec.yaml | 2 +- test/container_photo_sync_service_test.dart | 30 +- test/driver_schedule_generator_test.dart | 547 +++++++++++++ test/driver_schedule_screen_test.dart | 237 ++++++ test/inventory_controller_test.dart | 25 + test/packing_tab_test.dart | 124 ++- test/photo_service_test.dart | 32 + .../fake_container_photo_sync_service.dart | 3 + test/support/fake_inventory_sync_service.dart | 7 + 21 files changed, 2793 insertions(+), 54 deletions(-) create mode 100644 lib/src/models/driver_schedule.dart create mode 100644 lib/src/services/driver_schedule_generator.dart create mode 100644 lib/src/ui/driver_schedule_screen.dart create mode 100644 test/driver_schedule_generator_test.dart create mode 100644 test/driver_schedule_screen_test.dart diff --git a/README.md b/README.md index 2c04081..9417c25 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Builds are attached to [this repo's releases](https://github.com/Spectrum3847/sp ### Check the download first -Every artifact ships with a `.sha256` file next to it. Because these builds are unsigned, the instructions below ask you to click past your platform's own integrity check, so this is the only thing left that tells a good download from a corrupted or tampered one. Download both files into the same folder and run: +Every artifact ships with a `.sha256` file next to it. Because these builds are unsigned, the instructions below ask you to click past your platform's own integrity check. A colocated `.sha256` only detects a corrupted download: it comes from the same release, so an attacker who replaces both the artifact and its checksum supplies a matching sum. Detecting tampering needs the expected checksum from a separate trusted channel, or a signed artifact. Download both files into the same folder and run: ```bash sha256sum -c App-Name.zip.sha256 # Linux, and Git Bash on Windows diff --git a/firestore.rules b/firestore.rules index 877e28c..a6d4faa 100644 --- a/firestore.rules +++ b/firestore.rules @@ -81,7 +81,8 @@ service cloud.firestore { && isValidContainerPhoto(request.resource.data); allow update: if isAuthed() && isMember() && isValidContainerPhoto(request.resource.data) - && isMonotonicUpdate(resource.data, request.resource.data); + && isMonotonicUpdate(resource.data, request.resource.data) + && resource.data.location == request.resource.data.location; allow delete: if isAuthed() && isMember(); } diff --git a/lib/src/models/driver_schedule.dart b/lib/src/models/driver_schedule.dart new file mode 100644 index 0000000..8e83168 --- /dev/null +++ b/lib/src/models/driver_schedule.dart @@ -0,0 +1,460 @@ +library; + +class ScheduleRole { + const ScheduleRole({required this.key, required this.label}); + + final String key; + + final String label; +} + +class ScheduleInput { + const ScheduleInput({required this.key, required this.label, this.group}); + + final String key; + final String label; + + final String? group; +} + +class RoleSource { + const RoleSource({required this.roles, required this.inputs}); + + final List roles; + final List inputs; +} + +class HandoffPair { + const HandoffPair({required this.driver, required this.operator}); + + final String driver; + final String operator; +} + +class ScheduleGroup { + const ScheduleGroup({this.label, required this.roleKeys}); + + final String? label; + final List roleKeys; +} + +class AttendanceColumn { + const AttendanceColumn({required this.label, required this.roleKeys}); + + final String label; + final List roleKeys; +} + +class AttendanceView { + const AttendanceView({required this.label, required this.columns}); + + final String label; + final List columns; +} + +class ScheduleConfig { + const ScheduleConfig({ + required this.label, + required this.roles, + required this.inputs, + this.sources, + this.handoffPairs = const [], + this.renderGroups, + this.attendanceViews, + }); + + final String label; + + final List roles; + + final List inputs; + + final List? sources; + + final List handoffPairs; + + final List? renderGroups; + + final List? attendanceViews; + + List get roleKeys => [for (final role in roles) role.key]; + + Map get roleLabels => { + for (final role in roles) role.key: role.label, + }; + + List get effectiveSources => + sources ?? + [ + for (final role in roles) + RoleSource(roles: [role.key], inputs: [role.key]), + ]; + + List get effectiveRenderGroups => + renderGroups ?? [ScheduleGroup(roleKeys: roleKeys)]; + + List get effectiveAttendanceViews => + attendanceViews ?? + [ + AttendanceView( + label: 'Total', + columns: [ + for (final role in roles) + AttendanceColumn(label: role.label, roleKeys: [role.key]), + ], + ), + ]; +} + +const ScheduleConfig singleRobotConfig = ScheduleConfig( + label: 'One robot', + roles: [ + ScheduleRole(key: 'driver', label: 'Driver'), + ScheduleRole(key: 'operator', label: 'Operator'), + ScheduleRole(key: 'technician', label: 'Technician'), + ScheduleRole(key: 'humanPlayer', label: 'Human player'), + ], + inputs: [ + ScheduleInput(key: 'driver', label: 'Driver'), + ScheduleInput(key: 'operator', label: 'Operator'), + ScheduleInput(key: 'technician', label: 'Technician'), + ScheduleInput(key: 'humanPlayer', label: 'Human player'), + ], + handoffPairs: [HandoffPair(driver: 'driver', operator: 'operator')], +); + +const ScheduleConfig twoRobotConfig = ScheduleConfig( + label: 'Two robots', + roles: [ + ScheduleRole(key: 'r1driver', label: 'Driver'), + ScheduleRole(key: 'r1operator', label: 'Operator'), + ScheduleRole(key: 'r1technician', label: 'Technician'), + ScheduleRole(key: 'r1humanPlayer', label: 'Human player'), + ScheduleRole(key: 'r2driver', label: 'Driver'), + ScheduleRole(key: 'r2operator', label: 'Operator'), + ScheduleRole(key: 'r2technician', label: 'Technician'), + ScheduleRole(key: 'r2humanPlayer', label: 'Human player'), + ], + inputs: [ + ScheduleInput(key: 'r1driver', label: 'Driver', group: 'Robot 1'), + ScheduleInput(key: 'r1operator', label: 'Operator', group: 'Robot 1'), + ScheduleInput(key: 'r2driver', label: 'Driver', group: 'Robot 2'), + ScheduleInput(key: 'r2operator', label: 'Operator', group: 'Robot 2'), + ScheduleInput( + key: 'sharedTechnician', + label: 'Technician', + group: 'Shared by both robots', + ), + ScheduleInput( + key: 'sharedHumanPlayer', + label: 'Human player', + group: 'Shared by both robots', + ), + ], + sources: [ + RoleSource( + roles: ['r1driver', 'r2driver'], + inputs: ['r1driver', 'r2driver'], + ), + RoleSource( + roles: ['r1operator', 'r2operator'], + inputs: ['r1operator', 'r2operator'], + ), + RoleSource( + roles: ['r1technician', 'r2technician'], + inputs: ['sharedTechnician', 'sharedTechnician'], + ), + RoleSource( + roles: ['r1humanPlayer', 'r2humanPlayer'], + inputs: ['sharedHumanPlayer', 'sharedHumanPlayer'], + ), + ], + handoffPairs: [ + HandoffPair(driver: 'r1driver', operator: 'r1operator'), + HandoffPair(driver: 'r2driver', operator: 'r2operator'), + ], + renderGroups: [ + ScheduleGroup( + label: 'Robot 1', + roleKeys: ['r1driver', 'r1operator', 'r1technician', 'r1humanPlayer'], + ), + ScheduleGroup( + label: 'Robot 2', + roleKeys: ['r2driver', 'r2operator', 'r2technician', 'r2humanPlayer'], + ), + ], + attendanceViews: [ + AttendanceView( + label: 'Both robots', + columns: [ + AttendanceColumn(label: 'Driver', roleKeys: ['r1driver', 'r2driver']), + AttendanceColumn( + label: 'Operator', + roleKeys: ['r1operator', 'r2operator'], + ), + AttendanceColumn( + label: 'Technician', + roleKeys: ['r1technician', 'r2technician'], + ), + AttendanceColumn( + label: 'Human player', + roleKeys: ['r1humanPlayer', 'r2humanPlayer'], + ), + ], + ), + AttendanceView( + label: 'Robot 1', + columns: [ + AttendanceColumn(label: 'Driver', roleKeys: ['r1driver']), + AttendanceColumn(label: 'Operator', roleKeys: ['r1operator']), + AttendanceColumn(label: 'Technician', roleKeys: ['r1technician']), + AttendanceColumn(label: 'Human player', roleKeys: ['r1humanPlayer']), + ], + ), + AttendanceView( + label: 'Robot 2', + columns: [ + AttendanceColumn(label: 'Driver', roleKeys: ['r2driver']), + AttendanceColumn(label: 'Operator', roleKeys: ['r2operator']), + AttendanceColumn(label: 'Technician', roleKeys: ['r2technician']), + AttendanceColumn(label: 'Human player', roleKeys: ['r2humanPlayer']), + ], + ), + ], +); + +const List scheduleConfigs = [ + singleRobotConfig, + twoRobotConfig, +]; + +class DriverSchedule { + DriverSchedule({ + required this.config, + required this.slots, + required this.handoff, + required Map> columns, + required Map> rosters, + }) : _columns = columns, + _rosters = rosters; + + final ScheduleConfig config; + + final int slots; + + final bool handoff; + + final Map> _columns; + final Map> _rosters; + + String nameAt(String roleKey, int slot) { + final column = _columns[roleKey]; + if (column == null || slot < 0 || slot >= column.length) return ''; + return column[slot]; + } + + Set rosterFor(String roleKey) => _rosters[roleKey] ?? const {}; + + List get roleKeys => config.roleKeys; + + bool get isEmpty => config.roleKeys.every((key) => rosterFor(key).isEmpty); + + Set namesInSlot(int slot) => { + for (final key in config.roleKeys) + if (nameAt(key, slot).isNotEmpty) nameAt(key, slot), + }; +} + +class ScheduleHighlights { + const ScheduleHighlights._({ + required this.conflicts, + required this.backToBack, + required this.handoffs, + required this.handoffMissed, + }); + + final Set conflicts; + + final Set backToBack; + + final Set handoffs; + + final int handoffMissed; + + static String _cell(int slot, String roleKey) => '$slot,$roleKey'; + + bool isConflict(int slot, String roleKey) => + conflicts.contains(_cell(slot, roleKey)); + + bool isBackToBack(int slot, String roleKey) => + backToBack.contains(_cell(slot, roleKey)); + + bool isHandoff(int slot, String roleKey) => + handoffs.contains(_cell(slot, roleKey)); + + bool get isQuiet => + conflicts.isEmpty && + backToBack.isEmpty && + handoffs.isEmpty && + handoffMissed == 0; + + factory ScheduleHighlights.of(DriverSchedule schedule) { + final slots = schedule.slots; + final roleKeys = schedule.roleKeys; + + final pairs = schedule.handoff + ? schedule.config.handoffPairs + : const []; + + final handoffs = {}; + var handoffMissed = 0; + for (final pair in pairs) { + for (var slot = 0; slot < slots - 1; slot++) { + final nextDriver = schedule.nameAt(pair.driver, slot + 1); + if (nextDriver.isEmpty) continue; + if (schedule.nameAt(pair.operator, slot) == nextDriver) { + handoffs.add(_cell(slot, pair.operator)); + } else { + handoffMissed++; + } + } + } + final operatorFor = {for (final pair in pairs) pair.driver: pair.operator}; + + final conflicts = {}; + for (var slot = 0; slot < slots; slot++) { + final seen = {}; + for (final roleKey in roleKeys) { + final name = schedule.nameAt(roleKey, slot); + if (name.isEmpty) continue; + final firstRole = seen[name]; + if (firstRole == null) { + seen[name] = roleKey; + } else { + conflicts + ..add(_cell(slot, roleKey)) + ..add(_cell(slot, firstRole)); + } + } + } + + final backToBack = {}; + for (var slot = 1; slot < slots; slot++) { + final previous = schedule.namesInSlot(slot - 1); + for (final roleKey in roleKeys) { + final name = schedule.nameAt(roleKey, slot); + if (name.isEmpty || !previous.contains(name)) continue; + + final operatorKey = operatorFor[roleKey]; + if (operatorKey != null && + schedule.nameAt(operatorKey, slot - 1) == name && + roleKeys.every( + (other) => + other == operatorKey || + schedule.nameAt(other, slot - 1) != name, + )) { + continue; + } + backToBack.add(_cell(slot, roleKey)); + for (final other in roleKeys) { + if (schedule.nameAt(other, slot - 1) == name) { + backToBack.add(_cell(slot - 1, other)); + } + } + } + } + + return ScheduleHighlights._( + conflicts: conflicts, + backToBack: backToBack, + handoffs: handoffs, + handoffMissed: handoffMissed, + ); + } +} + +class AttendanceEntry { + const AttendanceEntry({ + required this.name, + required this.counts, + required this.total, + }); + + final String name; + + final List counts; + + final int total; +} + +class AttendanceTable { + const AttendanceTable._({required this.columns, required this.rows}); + + final List columns; + + final List rows; + + factory AttendanceTable.of(DriverSchedule schedule, AttendanceView view) { + final columns = view.columns; + final columnRosters = [ + for (final column in columns) + { + for (final roleKey in column.roleKeys) ...schedule.rosterFor(roleKey), + }, + ]; + + final counts = >{}; + final totals = {}; + List countsFor(String name) { + totals.putIfAbsent(name, () => 0); + return counts.putIfAbsent( + name, + () => List.filled(columns.length, 0), + ); + } + + for (final roster in columnRosters) { + for (final name in roster) { + countsFor(name); + } + } + + for (var slot = 0; slot < schedule.slots; slot++) { + final counted = {}; + for (var index = 0; index < columns.length; index++) { + for (final roleKey in columns[index].roleKeys) { + final name = schedule.nameAt(roleKey, slot); + if (name.isEmpty) continue; + countsFor(name)[index]++; + if (counted.add(name)) totals[name] = totals[name]! + 1; + } + } + } + + final rows = []; + for (final entry in counts.entries) { + final cells = []; + for (var index = 0; index < columns.length; index++) { + final count = entry.value[index]; + if (count > 0) { + cells.add(count); + } else { + cells.add(columnRosters[index].contains(entry.key) ? 0 : null); + } + } + rows.add( + AttendanceEntry( + name: entry.key, + counts: cells, + total: totals[entry.key]!, + ), + ); + } + rows.sort((a, b) { + final byTotal = b.total.compareTo(a.total); + return byTotal != 0 ? byTotal : a.name.compareTo(b.name); + }); + + return AttendanceTable._(columns: columns, rows: rows); + } +} diff --git a/lib/src/services/container_photo_sync_service.dart b/lib/src/services/container_photo_sync_service.dart index 4536ec0..892ae1d 100644 --- a/lib/src/services/container_photo_sync_service.dart +++ b/lib/src/services/container_photo_sync_service.dart @@ -1,16 +1,24 @@ +import 'dart:convert'; + import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:crypto/crypto.dart'; const String unlabeledDocId = 'unlabeled'; String containerPhotoDocId(String location) { - final slug = location - .trim() - .toLowerCase() - .replaceAll(RegExp('[^a-z0-9]+'), '-') - .replaceAll(RegExp('^-+|-+\$'), ''); - return slug.isEmpty ? unlabeledDocId : slug; + final trimmed = location.trim(); + final slug = _readableSlug(trimmed); + return slug.isEmpty ? unlabeledDocId : '$slug-${_hash8(trimmed)}'; } +String _readableSlug(String location) => location + .toLowerCase() + .replaceAll(RegExp('[^a-z0-9]+'), '-') + .replaceAll(RegExp('^-+|-+\$'), ''); + +String _hash8(String value) => + sha256.convert(utf8.encode(value)).toString().substring(0, 8); + abstract class ContainerPhotoSyncService { Future readKey(String location); diff --git a/lib/src/services/driver_schedule_generator.dart b/lib/src/services/driver_schedule_generator.dart new file mode 100644 index 0000000..caabe7a --- /dev/null +++ b/lib/src/services/driver_schedule_generator.dart @@ -0,0 +1,431 @@ +library; + +import 'dart:math'; + +import '../models/driver_schedule.dart'; + +const int maxScheduleSlots = 500; + +List parseNameList(String text) => text + .split(RegExp(r'[\n,]+')) + .map((name) => name.trim()) + .where((name) => name.isNotEmpty) + .toList(); + +String? validateSlotCount(String text) { + final value = int.tryParse(text.trim()); + if (value == null) return 'Enter how many matches to schedule'; + if (value < 1) return 'Schedule at least 1 match'; + if (value > maxScheduleSlots) { + return 'Schedule at most $maxScheduleSlots matches'; + } + return null; +} + +String scheduleAsTabSeparatedText( + DriverSchedule schedule, + ScheduleGroup group, +) { + final labels = schedule.config.roleLabels; + final lines = [ + ['#', for (final key in group.roleKeys) labels[key] ?? key].join('\t'), + ]; + for (var slot = 0; slot < schedule.slots; slot++) { + lines.add( + [ + '${slot + 1}', + for (final key in group.roleKeys) + schedule.nameAt(key, slot).isEmpty ? '-' : schedule.nameAt(key, slot), + ].join('\t'), + ); + } + return lines.join('\n'); +} + +class DriverScheduleGenerator { + DriverScheduleGenerator({Random? random}) : _random = random ?? Random(); + + final Random _random; + + DriverSchedule generate( + ScheduleConfig config, + Map> inputs, { + required int slots, + required bool handoff, + }) { + if (slots < 1 || slots > maxScheduleSlots) { + throw ArgumentError.value(slots, 'slots', 'must be 1..$maxScheduleSlots'); + } + + final columns = >{}; + final rosters = >{}; + for (final source in config.effectiveSources) { + final lists = [ + for (final key in source.inputs) inputs[key] ?? const [], + ]; + final filled = _pooledSpread(lists, slots); + for (var index = 0; index < source.roles.length; index++) { + rosters[source.roles[index]] = {...lists[index]}; + columns[source.roles[index]] = filled[index]; + } + } + + final roleKeys = config.roleKeys; + + _resolveConflicts(columns, slots, roleKeys); + for (var round = 0; round < 20; round++) { + final movedAdjacent = _resolveBackToBack(columns, slots, roleKeys); + final movedConflict = _resolveConflicts(columns, slots, roleKeys); + if (!movedAdjacent && !movedConflict) break; + } + + if (handoff) { + for (final pair in config.handoffPairs) { + _spreadForHandoff(columns, slots, pair, roleKeys); + _applyHandoff(columns, slots, pair, roleKeys); + } + } + + return DriverSchedule( + config: config, + slots: slots, + handoff: handoff, + columns: columns, + rosters: rosters, + ); + } + + List> _pooledSpread(List> lists, int slots) { + final count = lists.length; + final columns = List.generate(count, (_) => List.filled(slots, '')); + final weight = {}; + final eligible = >{}; + final priority = {}; + for (var column = 0; column < count; column++) { + final local = {}; + for (var index = 0; index < lists[column].length; index++) { + final name = lists[column][index]; + local[name] = (local[name] ?? 0) + 1; + priority.putIfAbsent(name, () => index); + } + local.forEach((name, listed) { + weight[name] = max(weight[name] ?? 0, listed); + eligible.putIfAbsent(name, () => {}).add(column); + }); + } + + final names = weight.keys.toList(); + final assigned = {for (final name in names) name: 0}; + final previousInColumn = List.filled(count, ''); + for (var slot = 0; slot < slots; slot++) { + final used = {}; + for (var column = 0; column < count; column++) { + final candidates = [ + for (final name in names) + if (eligible[name]!.contains(column)) name, + ]; + + if (candidates.isEmpty) continue; + + var fairest = double.infinity; + for (final name in candidates) { + fairest = min(fairest, assigned[name]! / weight[name]!); + } + final ties = [ + for (final name in candidates) + if ((assigned[name]! / weight[name]! - fairest).abs() < 1e-9) name, + ]; + var preferred = [ + for (final name in ties) + if (name != previousInColumn[column] && !used.contains(name)) name, + ]; + if (preferred.isEmpty) { + preferred = [ + for (final name in ties) + if (!used.contains(name)) name, + ]; + } + if (preferred.isEmpty) { + preferred = [ + for (final name in ties) + if (name != previousInColumn[column]) name, + ]; + } + if (preferred.isEmpty) preferred = ties; + + final picked = preferred.length == 1 + ? preferred.first + : _pickByPriority(preferred, priority); + columns[column][slot] = picked; + used.add(picked); + assigned[picked] = assigned[picked]! + 1; + previousInColumn[column] = picked; + } + } + return columns; + } + + String _pickByPriority(List candidates, Map priority) { + final ordered = [...candidates] + ..sort((a, b) => priority[a]!.compareTo(priority[b]!)); + final weights = [ + for (var i = 0; i < ordered.length; i++) ordered.length - i, + ]; + final total = weights.reduce((a, b) => a + b); + var roll = _random.nextDouble() * total; + for (var index = 0; index < ordered.length; index++) { + roll -= weights[index]; + if (roll <= 0) return ordered[index]; + } + return ordered.last; + } + + bool _resolveConflicts( + Map> columns, + int slots, + List roleKeys, + ) { + var changed = false; + for (var pass = 0; pass < 300; pass++) { + var improved = false; + for (var slot = 0; slot < slots; slot++) { + final seen = {}; + for (final roleKey in roleKeys) { + final name = _at(columns, roleKey, slot); + if (name.isEmpty) continue; + if (seen.add(name)) continue; + + for (var other = 0; other < slots; other++) { + if (other == slot) continue; + final candidate = _at(columns, roleKey, other); + if (candidate.isEmpty || candidate == name) continue; + if (_othersIn( + columns, + slot, + roleKey, + roleKeys, + ).contains(candidate)) { + continue; + } + if (_othersIn(columns, other, roleKey, roleKeys).contains(name)) { + continue; + } + _swap(columns, roleKey, slot, other); + improved = true; + break; + } + } + } + if (!improved) break; + changed = true; + } + return changed; + } + + bool _resolveBackToBack( + Map> columns, + int slots, + List roleKeys, + ) { + var changed = false; + for (var pass = 0; pass < 300; pass++) { + var improved = false; + for (var slot = 1; slot < slots; slot++) { + final previous = _namesIn(columns, slot - 1, roleKeys); + for (final roleKey in roleKeys) { + final name = _at(columns, roleKey, slot); + if (name.isEmpty || !previous.contains(name)) continue; + for (var other = 0; other < slots; other++) { + if (other == slot) continue; + final candidate = _at(columns, roleKey, other); + + if (candidate.isEmpty || previous.contains(candidate)) continue; + if (_othersIn( + columns, + slot, + roleKey, + roleKeys, + ).contains(candidate)) { + continue; + } + if (_othersIn(columns, other, roleKey, roleKeys).contains(name)) { + continue; + } + + if (other > 0 && + other - 1 != slot && + _namesIn(columns, other - 1, roleKeys).contains(name)) { + continue; + } + if (other < slots - 1 && + other + 1 != slot && + _namesIn(columns, other + 1, roleKeys).contains(name)) { + continue; + } + _swap(columns, roleKey, slot, other); + improved = true; + break; + } + } + } + if (!improved) break; + changed = true; + } + return changed; + } + + void _spreadForHandoff( + Map> columns, + int slots, + HandoffPair pair, + List roleKeys, + ) { + final driverColumn = columns[pair.driver]; + if (driverColumn == null) return; + + final pool = {}; + for (final name in driverColumn) { + if (name.isNotEmpty) pool[name] = (pool[name] ?? 0) + 1; + } + + final outsidePair = [ + for (final key in roleKeys) + if (key != pair.driver && key != pair.operator) key, + ]; + Set fixedAt(int slot) => _namesIn(columns, slot, outsidePair); + + final out = List.filled(slots, ''); + for (var slot = 0; slot < slots; slot++) { + final clash = fixedAt(slot); + final adjacent = slot > 0 ? fixedAt(slot - 1) : {}; + final previous = slot > 0 ? out[slot - 1] : ''; + final twoBack = slot > 1 ? out[slot - 2] : ''; + if (previous.isNotEmpty) adjacent.add(previous); + + final rules = [ + (name) => + !clash.contains(name) && + name != twoBack && + !adjacent.contains(name), + (name) => !clash.contains(name) && name != twoBack && name != previous, + (name) => !clash.contains(name) && name != previous, + (name) => !clash.contains(name), + (name) => true, + ]; + + var picked = ''; + for (final rule in rules) { + var most = 0; + final ties = []; + pool.forEach((name, left) { + if (left == 0 || !rule(name)) return; + if (left > most) { + most = left; + ties + ..clear() + ..add(name); + } else if (left == most) { + ties.add(name); + } + }); + if (ties.isNotEmpty) { + picked = ties[_random.nextInt(ties.length)]; + break; + } + } + + if (picked.isEmpty) break; + out[slot] = picked; + pool[picked] = pool[picked]! - 1; + } + columns[pair.driver] = out; + } + + void _applyHandoff( + Map> columns, + int slots, + HandoffPair pair, + List roleKeys, + ) { + final operatorColumn = columns[pair.operator]; + if (columns[pair.driver] == null || operatorColumn == null) return; + + final pool = {}; + for (final name in operatorColumn) { + if (name.isNotEmpty) pool[name] = (pool[name] ?? 0) + 1; + } + + final out = List.filled(slots, ''); + for (var slot = 0; slot < slots - 1; slot++) { + final wanted = _at(columns, pair.driver, slot + 1); + if (wanted.isEmpty || (pool[wanted] ?? 0) == 0) continue; + if (_othersIn(columns, slot, pair.operator, roleKeys).contains(wanted)) { + continue; + } + pool[wanted] = pool[wanted]! - 1; + out[slot] = wanted; + } + + final spare = []; + pool.forEach((name, left) { + for (var i = 0; i < left; i++) { + spare.add(name); + } + }); + for (var slot = 0; slot < slots && spare.isNotEmpty; slot++) { + if (out[slot].isNotEmpty) continue; + final clash = _othersIn(columns, slot, pair.operator, roleKeys); + final before = slot > 0 ? out[slot - 1] : ''; + final after = slot < slots - 1 ? out[slot + 1] : ''; + var index = spare.indexWhere( + (name) => !clash.contains(name) && name != before && name != after, + ); + if (index < 0) index = spare.indexWhere((name) => !clash.contains(name)); + if (index < 0) index = 0; + out[slot] = spare.removeAt(index); + } + columns[pair.operator] = out; + } + + static String _at( + Map> columns, + String roleKey, + int slot, + ) { + final column = columns[roleKey]; + if (column == null || slot < 0 || slot >= column.length) return ''; + return column[slot]; + } + + static Set _namesIn( + Map> columns, + int slot, + List roleKeys, + ) => { + for (final key in roleKeys) + if (_at(columns, key, slot).isNotEmpty) _at(columns, key, slot), + }; + + static Set _othersIn( + Map> columns, + int slot, + String roleKey, + List roleKeys, + ) => { + for (final key in roleKeys) + if (key != roleKey && _at(columns, key, slot).isNotEmpty) + _at(columns, key, slot), + }; + + static void _swap( + Map> columns, + String roleKey, + int a, + int b, + ) { + final column = columns[roleKey]!; + final held = column[a]; + column[a] = column[b]; + column[b] = held; + } +} diff --git a/lib/src/services/photo_service.dart b/lib/src/services/photo_service.dart index 828e0e4..6d75de1 100644 --- a/lib/src/services/photo_service.dart +++ b/lib/src/services/photo_service.dart @@ -162,6 +162,7 @@ class PhotoService { void close() => _client.close(); Future clearCache() async { + await Future.wait(_diskOps.values.map((op) => op.catchError((_) {}))); _cache.clear(); await _diskCache?.clear(); } diff --git a/lib/src/state/pit_controller_mixin.dart b/lib/src/state/pit_controller_mixin.dart index 7841159..b01a266 100644 --- a/lib/src/state/pit_controller_mixin.dart +++ b/lib/src/state/pit_controller_mixin.dart @@ -34,6 +34,8 @@ mixin PitControllerMixin on ChangeNotifier { final Map _pitMutations = {}; + Map _pitConfirmed = {}; + int _pitBeginMutation(String id) => _pitMutations[id] = (_pitMutations[id] ?? 0) + 1; @@ -74,7 +76,6 @@ mixin PitControllerMixin on ChangeNotifier { Future upsert(T item) async { final mutation = _pitBeginMutation(item.id); final previousIndex = _pitItems.indexWhere((e) => e.id == item.id); - final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ for (final existing in _pitItems) if (existing.id != item.id) existing, @@ -83,6 +84,7 @@ mixin PitControllerMixin on ChangeNotifier { notifyListeners(); try { await _pitQueueRemote(item.id, () => pitUpsertRemote(item)); + _pitConfirmed[item.id] = item; } catch (_) { if (!_pitIsNewestMutation(item.id, mutation)) { await _pitSaveCache().catchError((_) {}); @@ -92,8 +94,10 @@ mixin PitControllerMixin on ChangeNotifier { for (final existing in _pitItems) if (existing.id != item.id) existing, ]; - if (previousItem != null) { - restored.insert(previousIndex.clamp(0, restored.length), previousItem); + + final confirmed = _pitConfirmed[item.id]; + if (confirmed != null) { + restored.insert(previousIndex.clamp(0, restored.length), confirmed); } _pitItems = restored; notifyListeners(); @@ -108,7 +112,6 @@ mixin PitControllerMixin on ChangeNotifier { Future delete(String id) async { final mutation = _pitBeginMutation(id); final previousIndex = _pitItems.indexWhere((e) => e.id == id); - final previousItem = previousIndex < 0 ? null : _pitItems[previousIndex]; _pitItems = [ for (final existing in _pitItems) if (existing.id != id) existing, @@ -116,17 +119,20 @@ mixin PitControllerMixin on ChangeNotifier { notifyListeners(); try { await _pitQueueRemote(id, () => pitDeleteRemote(id)); + _pitConfirmed.remove(id); } catch (_) { if (!_pitIsNewestMutation(id, mutation)) { await _pitSaveCache().catchError((_) {}); rethrow; } - if (previousItem != null) { + + final confirmed = _pitConfirmed[id]; + if (confirmed != null) { final restored = [ for (final existing in _pitItems) if (existing.id != id) existing, ]; - restored.insert(previousIndex.clamp(0, restored.length), previousItem); + restored.insert(previousIndex.clamp(0, restored.length), confirmed); _pitItems = restored; } notifyListeners(); @@ -168,6 +174,8 @@ mixin PitControllerMixin on ChangeNotifier { (items) { if (gen != _pitStreamGeneration) return; _pitItems = items; + + _pitConfirmed = {for (final item in items) item.id: item}; _pitSaveCache(); notifyListeners(); }, diff --git a/lib/src/ui/app_shell.dart b/lib/src/ui/app_shell.dart index 9c6ee03..833a3ad 100644 --- a/lib/src/ui/app_shell.dart +++ b/lib/src/ui/app_shell.dart @@ -198,8 +198,13 @@ class _AppShellState extends State { return _featureTabIndices.map((i) { final m = _kTabMeta[i]; final showBadge = i == AppTabs.borrowed && overdueCount > 0; - Widget withBadge(Icon icon) => - showBadge ? Badge(label: Text('$overdueCount'), child: icon) : icon; + Widget withBadge(Icon icon) => showBadge + ? Semantics( + label: + '$overdueCount overdue ${overdueCount == 1 ? 'loan' : 'loans'}', + child: Badge(label: Text('$overdueCount'), child: icon), + ) + : icon; return NavigationDestination( icon: withBadge(Icon(m.icon)), selectedIcon: withBadge(Icon(m.selectedIcon)), diff --git a/lib/src/ui/borrow_tab.dart b/lib/src/ui/borrow_tab.dart index e6ee299..6bab890 100644 --- a/lib/src/ui/borrow_tab.dart +++ b/lib/src/ui/borrow_tab.dart @@ -771,6 +771,8 @@ class _BorrowEditorSheetState extends State<_BorrowEditorSheet> { const SizedBox(height: 12), TextField( controller: _contact, + maxLength: 256, + keyboardType: TextInputType.text, decoration: const InputDecoration( labelText: 'Contact (optional)', hintText: 'Phone or email', diff --git a/lib/src/ui/driver_schedule_screen.dart b/lib/src/ui/driver_schedule_screen.dart new file mode 100644 index 0000000..b57e7db --- /dev/null +++ b/lib/src/ui/driver_schedule_screen.dart @@ -0,0 +1,721 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; + +import '../models/driver_schedule.dart'; +import '../services/driver_schedule_generator.dart'; +import '../theme/app_theme.dart'; +import '../theme/pit_palette.dart'; + +class DriverScheduleScreen extends StatefulWidget { + const DriverScheduleScreen({super.key, this.generator}); + + final DriverScheduleGenerator? generator; + + @override + State createState() => _DriverScheduleScreenState(); +} + +class _DriverScheduleScreenState extends State { + late final DriverScheduleGenerator _generator = + widget.generator ?? DriverScheduleGenerator(); + + final Map _nameControllers = {}; + final TextEditingController _slotsController = TextEditingController( + text: '6', + ); + + int _modeIndex = 0; + bool _handoff = false; + String? _slotsError; + DriverSchedule? _schedule; + + ScheduleConfig get _config => scheduleConfigs[_modeIndex]; + + @override + void dispose() { + for (final controller in _nameControllers.values) { + controller.dispose(); + } + _slotsController.dispose(); + super.dispose(); + } + + TextEditingController _controllerFor(String inputKey) => + _nameControllers.putIfAbsent(inputKey, TextEditingController.new); + + void _selectMode(int index) { + if (index == _modeIndex) return; + setState(() { + _modeIndex = index; + + _schedule = null; + }); + } + + void _setHandoff(bool value) { + setState(() => _handoff = value); + + if (_schedule != null) _generate(); + } + + void _generate() { + final error = validateSlotCount(_slotsController.text); + if (error != null) { + setState(() { + _slotsError = error; + _schedule = null; + }); + return; + } + + final config = _config; + final inputs = { + for (final input in config.inputs) + input.key: parseNameList(_controllerFor(input.key).text), + }; + setState(() { + _slotsError = null; + _schedule = _generator.generate( + config, + inputs, + slots: int.parse(_slotsController.text.trim()), + handoff: _handoff, + ); + }); + } + + @override + Widget build(BuildContext context) { + final config = _config; + final muted = PitPalette.inkMutedOf(context); + final schedule = _schedule; + + return Scaffold( + appBar: AppBar(title: const Text('Driver schedule')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + Text( + 'Generate a balanced match rotation. Repeat a name to give that ' + 'person a bigger share; names listed first pick up any spare ' + 'turns.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), + ), + const SizedBox(height: 16), + SegmentedButton( + segments: [ + for (var index = 0; index < scheduleConfigs.length; index++) + ButtonSegment( + value: index, + label: Text(scheduleConfigs[index].label), + ), + ], + selected: {_modeIndex}, + onSelectionChanged: (selection) => _selectMode(selection.first), + ), + const SizedBox(height: 20), + ..._nameFields(context, config), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 132, + child: TextField( + controller: _slotsController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _generate(), + decoration: InputDecoration( + labelText: 'Matches', + errorText: _slotsError, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: FilledButton.icon( + onPressed: _generate, + icon: const Icon(Icons.casino_outlined), + label: const Text('Generate'), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _handoff, + onChanged: _setHandoff, + title: const Text('Next driver operates first'), + subtitle: Text( + 'Each match is operated by whoever drives the match after it.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: muted), + ), + ), + const SizedBox(height: 20), + if (schedule != null) _ScheduleOutput(schedule: schedule), + ], + ), + ); + } + + List _nameFields(BuildContext context, ScheduleConfig config) { + final widgets = []; + String? currentGroup; + for (final input in config.inputs) { + if (input.group != null && input.group != currentGroup) { + widgets.add( + Padding( + padding: EdgeInsets.only(top: widgets.isEmpty ? 0 : 8, bottom: 10), + child: Text( + input.group!, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ); + } + currentGroup = input.group ?? currentGroup; + widgets.add( + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextField( + controller: _controllerFor(input.key), + minLines: 2, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + labelText: input.label, + hintText: 'One name per line', + alignLabelWithHint: true, + ), + ), + ), + ); + } + return widgets; + } +} + +class _ScheduleOutput extends StatefulWidget { + const _ScheduleOutput({required this.schedule}); + + final DriverSchedule schedule; + + @override + State<_ScheduleOutput> createState() => _ScheduleOutputState(); +} + +class _ScheduleOutputState extends State<_ScheduleOutput> { + String? _selectedName; + int _viewIndex = 0; + + @override + void didUpdateWidget(_ScheduleOutput oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.schedule, oldWidget.schedule)) { + _selectedName = null; + _viewIndex = 0; + } + } + + void _toggleName(String name) { + setState(() => _selectedName = _selectedName == name ? null : name); + } + + Future _copy(ScheduleGroup group) async { + await Clipboard.setData( + ClipboardData(text: scheduleAsTabSeparatedText(widget.schedule, group)), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + group.label == null + ? 'Schedule copied' + : '${group.label} schedule copied', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final schedule = widget.schedule; + if (schedule.isEmpty) { + return const _NoNamesYet(); + } + + final highlights = ScheduleHighlights.of(schedule); + final views = schedule.config.effectiveAttendanceViews; + final viewIndex = _viewIndex < views.length ? _viewIndex : 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final group in schedule.config.effectiveRenderGroups) ...[ + _ScheduleChart( + schedule: schedule, + highlights: highlights, + group: group, + selectedName: _selectedName, + onCopy: () => _copy(group), + ), + const SizedBox(height: 20), + ], + if (!highlights.isQuiet) ...[ + _Legend(highlights: highlights), + const SizedBox(height: 24), + ], + _AttendanceSection( + schedule: schedule, + views: views, + viewIndex: viewIndex, + selectedName: _selectedName, + onSelectView: (index) => setState(() => _viewIndex = index), + onSelectName: _toggleName, + ), + ], + ); + } +} + +class _NoNamesYet extends StatelessWidget { + const _NoNamesYet(); + + @override + Widget build(BuildContext context) { + final muted = PitPalette.inkMutedOf(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: muted), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Add at least one person to a role above, then generate.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), + ), + ), + ], + ); + } +} + +class _ScheduleChart extends StatelessWidget { + const _ScheduleChart({ + required this.schedule, + required this.highlights, + required this.group, + required this.selectedName, + required this.onCopy, + }); + + final DriverSchedule schedule; + final ScheduleHighlights highlights; + final ScheduleGroup group; + final String? selectedName; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final labels = schedule.config.roleLabels; + final muted = PitPalette.inkMutedOf(context); + final outline = PitPalette.outlineOf(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + group.label ?? 'Schedule', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton.icon( + onPressed: onCopy, + icon: const Icon(Icons.content_copy_outlined, size: 18), + label: const Text('Copy'), + ), + ], + ), + const SizedBox(height: 8), + _Framed( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + + columnWidths: const {0: FixedColumnWidth(48)}, + defaultColumnWidth: const MaxColumnWidth( + IntrinsicColumnWidth(), + FixedColumnWidth(116), + ), + border: TableBorder( + horizontalInside: BorderSide(color: outline), + verticalInside: BorderSide(color: outline), + ), + children: [ + TableRow( + children: [ + const _HeaderCell(label: '#'), + for (final roleKey in group.roleKeys) + _HeaderCell(label: labels[roleKey] ?? roleKey), + ], + ), + for (var slot = 0; slot < schedule.slots; slot++) + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Text( + '${slot + 1}', + style: pitCodeStyle(context, color: muted), + ), + ), + for (final roleKey in group.roleKeys) + _NameCell( + name: schedule.nameAt(roleKey, slot), + flag: _flagFor(slot, roleKey), + selected: + selectedName != null && + schedule.nameAt(roleKey, slot) == selectedName, + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + _CellFlag? _flagFor(int slot, String roleKey) { + if (highlights.isConflict(slot, roleKey)) return _CellFlag.conflict; + if (highlights.isBackToBack(slot, roleKey)) return _CellFlag.backToBack; + if (highlights.isHandoff(slot, roleKey)) return _CellFlag.handoff; + return null; + } +} + +enum _CellFlag { + conflict('Same-slot conflict'), + backToBack('Back-to-back'), + handoff('Operates, then drives next match'); + + const _CellFlag(this.label); + + final String label; + + Color colorOf(BuildContext context) => switch (this) { + _CellFlag.conflict => PitPalette.statusOverdueOf(context), + _CellFlag.backToBack => PitPalette.statusPackingOf(context), + _CellFlag.handoff => PitPalette.statusReadyOf(context), + }; +} + +class _HeaderCell extends StatelessWidget { + const _HeaderCell({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Text(label, style: Theme.of(context).textTheme.labelLarge), + ); + } +} + +class _NameCell extends StatelessWidget { + const _NameCell({ + required this.name, + required this.flag, + required this.selected, + }); + + final String name; + final _CellFlag? flag; + final bool selected; + + @override + Widget build(BuildContext context) { + final accent = PitPalette.accentOf(context); + + final cellFlag = flag; + final label = name.isEmpty ? 'Nobody' : name; + + return Semantics( + label: cellFlag == null ? label : '$label, ${cellFlag.label}', + excludeSemantics: true, + child: Container( + decoration: BoxDecoration( + color: cellFlag?.colorOf(context).withValues(alpha: 0.20), + border: selected ? Border.all(color: accent, width: 2) : null, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Text( + name.isEmpty ? '-' : name, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + +class _Legend extends StatelessWidget { + const _Legend({required this.highlights}); + + final ScheduleHighlights highlights; + + @override + Widget build(BuildContext context) { + final muted = PitPalette.inkMutedOf(context); + final warnings = [ + if (highlights.conflicts.isNotEmpty) + 'Someone is booked into two roles in the same match. Add more names ' + 'to that role to resolve it.', + if (highlights.backToBack.isNotEmpty) + 'Some back-to-back matches were unavoidable with the names given.', + if (highlights.handoffMissed > 0) + '${highlights.handoffMissed} ' + '${highlights.handoffMissed == 1 ? 'match' : 'matches'} could not ' + 'hand off: the next driver had no operator turn to spare. Add ' + 'them to the operator list for a closer match.', + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (highlights.conflicts.isNotEmpty) + const _LegendItem(flag: _CellFlag.conflict), + if (highlights.backToBack.isNotEmpty) + const _LegendItem(flag: _CellFlag.backToBack), + if (highlights.handoffs.isNotEmpty) + const _LegendItem(flag: _CellFlag.handoff), + ], + ), + for (final warning in warnings) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: muted), + const SizedBox(width: 8), + Expanded( + child: Text( + warning, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: muted), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _LegendItem extends StatelessWidget { + const _LegendItem({required this.flag}); + + final _CellFlag flag; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: flag.colorOf(context).withValues(alpha: 0.20), + border: Border.all(color: flag.colorOf(context)), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text(flag.label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} + +class _AttendanceSection extends StatelessWidget { + const _AttendanceSection({ + required this.schedule, + required this.views, + required this.viewIndex, + required this.selectedName, + required this.onSelectView, + required this.onSelectName, + }); + + final DriverSchedule schedule; + final List views; + final int viewIndex; + final String? selectedName; + final ValueChanged onSelectView; + final ValueChanged onSelectName; + + @override + Widget build(BuildContext context) { + final muted = PitPalette.inkMutedOf(context); + final outline = PitPalette.outlineOf(context); + final accent = PitPalette.accentOf(context); + final table = AttendanceTable.of(schedule, views[viewIndex]); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Matches per person', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + 'Tap a name to highlight their matches above.', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: muted), + ), + const SizedBox(height: 12), + if (views.length > 1) ...[ + SegmentedButton( + segments: [ + for (var index = 0; index < views.length; index++) + ButtonSegment( + value: index, + label: Text(views[index].label), + ), + ], + selected: {viewIndex}, + onSelectionChanged: (selection) => onSelectView(selection.first), + ), + const SizedBox(height: 12), + ], + _Framed( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + defaultColumnWidth: const MaxColumnWidth( + IntrinsicColumnWidth(), + FixedColumnWidth(104), + ), + border: TableBorder(horizontalInside: BorderSide(color: outline)), + children: [ + TableRow( + children: [ + const _HeaderCell(label: 'Person'), + for (final column in table.columns) + _HeaderCell(label: column.label), + const _HeaderCell(label: 'Matches'), + ], + ), + for (final entry in table.rows) + TableRow( + decoration: entry.name == selectedName + ? BoxDecoration(color: accent.withValues(alpha: 0.16)) + : null, + children: [ + _AttendanceCell( + onTap: () => onSelectName(entry.name), + child: Text( + entry.name, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + for (final count in entry.counts) + _AttendanceCell( + onTap: () => onSelectName(entry.name), + child: count == null + ? Text( + 'not listed', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: muted), + ) + : Text( + '$count', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + _AttendanceCell( + onTap: () => onSelectName(entry.name), + child: Text( + '${entry.total}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +class _AttendanceCell extends StatelessWidget { + const _AttendanceCell({required this.child, required this.onTap}); + + final Widget child; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: child, + ), + ); + } +} + +class _Framed extends StatelessWidget { + const _Framed({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: PitPalette.surfaceOf(context), + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + border: Border.all(color: PitPalette.outlineOf(context)), + ), + child: child, + ); + } +} diff --git a/lib/src/ui/packing_tab.dart b/lib/src/ui/packing_tab.dart index 9a15c24..399fa30 100644 --- a/lib/src/ui/packing_tab.dart +++ b/lib/src/ui/packing_tab.dart @@ -36,10 +36,14 @@ class PackingTab extends StatefulWidget { class _PackingTabState extends State { final Set _busy = {}; + final Set _advancing = {}; + final Set _busyContainerPhotos = {}; final Set _containerPhotoChecks = {}; + final Set _containerPhotoLoadFailed = {}; + final Map _containerPhotoKeys = {}; @override @@ -63,7 +67,8 @@ class _PackingTabState extends State { final entry = entries[i]; final location = entry.location; if (location != null) { - if (!_containerPhotoChecks.contains(location)) { + if (!_containerPhotoChecks.contains(location) && + !_containerPhotoLoadFailed.contains(location)) { _containerPhotoChecks.add(location); _loadContainerPhoto(location); } @@ -71,6 +76,9 @@ class _PackingTabState extends State { location: location, hasPhoto: _containerPhotoKeys[location] != null, busy: _busyContainerPhotos.contains(location), + loadFailed: _containerPhotoLoadFailed.contains( + location, + ), onPhotoTap: () => _handleContainerPhoto(location), ); } @@ -103,10 +111,13 @@ class _PackingTabState extends State { } Future _advance(_PackingRowData row) { + final id = row.record.id; + if (_advancing.contains(id)) return Future.value(); + _advancing.add(id); final status = _nextStatus(row.record.packingStatus); final record = row.virtual ? PackingRecord( - id: const Uuid().v4(), + id: id, itemId: row.record.itemId, packingStatus: status, updatedAt: DateTime.now().toUtc(), @@ -120,7 +131,8 @@ class _PackingTabState extends State { .catchError( (Object error) => _showFailure('update "${row.record.itemId}"', error), - ); + ) + .whenComplete(() => _advancing.remove(id)); } Future _handlePhoto(PackingRecord record) { @@ -207,15 +219,30 @@ class _PackingTabState extends State { Future _loadContainerPhoto(String location) async { String? key; + var failed = false; try { key = await widget.containerPhotoSyncService.readKey(location); - } catch (_) {} + } catch (_) { + failed = true; + } if (!mounted) return; - setState(() => _containerPhotoKeys[location] = key); + setState(() { + if (failed) { + _containerPhotoLoadFailed.add(location); + _containerPhotoChecks.remove(location); + } else { + _containerPhotoLoadFailed.remove(location); + _containerPhotoKeys[location] = key; + } + }); } Future _handleContainerPhoto(String location) { if (_busyContainerPhotos.contains(location)) return Future.value(); + if (_containerPhotoLoadFailed.contains(location)) { + _containerPhotoChecks.add(location); + return _loadContainerPhoto(location); + } final key = _containerPhotoKeys[location]; return key == null ? _captureContainerPhoto(location) @@ -229,28 +256,27 @@ class _PackingTabState extends State { ); if (source == null || !mounted) return; setState(() => _busyContainerPhotos.add(location)); + final previous = _containerPhotoKeys[location]; String? key; try { key = await widget.photoService.capture(source); + if (key == null) return; + if (!mounted) { + await _deleteKey(key, location); + return; + } + await widget.containerPhotoSyncService.writeKey(location, key); + if (!mounted) return; + setState(() => _containerPhotoKeys[location] = key); + if (previous != null && previous != key) { + await _deleteKey(previous, location); + } } catch (error) { _showFailure('add a photo to "$location"', error); + if (key != null) await _deleteKey(key, location); } finally { if (mounted) setState(() => _busyContainerPhotos.remove(location)); } - if (key == null) return; - if (!mounted) { - await _deleteKey(key, location); - return; - } - try { - await widget.containerPhotoSyncService.writeKey(location, key); - } catch (error) { - _showFailure('save the photo for "$location"', error); - await _deleteKey(key, location); - return; - } - if (!mounted) return; - setState(() => _containerPhotoKeys[location] = key); } Future _openContainerPhoto(String location, String key) async { @@ -605,12 +631,14 @@ class _LocationHeader extends StatelessWidget { required this.location, required this.hasPhoto, required this.busy, + required this.loadFailed, required this.onPhotoTap, }); final String location; final bool hasPhoto; final bool busy; + final bool loadFailed; final VoidCallback onPhotoTap; @override @@ -628,7 +656,9 @@ class _LocationHeader extends StatelessWidget { ), IconButton( onPressed: busy ? null : onPhotoTap, - tooltip: hasPhoto + tooltip: loadFailed + ? 'Could not check for a container photo -- tap to retry' + : hasPhoto ? 'Open the container photo' : 'Add a container photo', icon: busy @@ -638,10 +668,14 @@ class _LocationHeader extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( - hasPhoto + loadFailed + ? Icons.cloud_off_rounded + : hasPhoto ? Icons.photo_outlined : Icons.photo_camera_outlined, - color: hasPhoto ? PitPalette.accentOf(context) : muted, + color: hasPhoto && !loadFailed + ? PitPalette.accentOf(context) + : muted, ), ), ], @@ -1103,7 +1137,7 @@ class _RecordEditorSheetState extends State<_RecordEditorSheet> { try { committed = await widget.onSubmit( PackingRecord( - id: existing?.id ?? const Uuid().v4(), + id: item?.id ?? existing?.id ?? const Uuid().v4(), itemId: itemId, packingStatus: _status, photoRef: _photoRef, diff --git a/lib/src/ui/schedule_tab.dart b/lib/src/ui/schedule_tab.dart index 73582d1..e18b7ef 100644 --- a/lib/src/ui/schedule_tab.dart +++ b/lib/src/ui/schedule_tab.dart @@ -12,6 +12,7 @@ import '../state/user_role_controller.dart'; import '../theme/app_theme.dart'; import '../theme/pit_palette.dart'; import '../widgets/keyboard_shortcuts.dart'; +import 'driver_schedule_screen.dart'; class ScheduleTab extends StatefulWidget { const ScheduleTab({ @@ -44,7 +45,10 @@ class _ScheduleTabState extends State { return Stack( children: [ competition == null - ? _EmptySchedule(onAdd: () => _openEditor()) + ? _EmptySchedule( + onAdd: () => _openEditor(), + onDriverSchedule: _openDriverSchedule, + ) : _buildSchedule(context, competitions, competition), Positioned( right: 16, @@ -105,6 +109,7 @@ class _ScheduleTabState extends State { child: ListView( padding: const EdgeInsets.fromLTRB(16, 4, 16, 96), children: [ + _DriverScheduleEntry(onOpen: _openDriverSchedule), if (shownConflicts.isNotEmpty) _ConflictPanel(conflicts: shownConflicts), if (rows.isEmpty) @@ -149,6 +154,12 @@ class _ScheduleTabState extends State { conflict.first.assignedUids.contains(uid) && conflict.second.assignedUids.contains(uid); + void _openDriverSchedule() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DriverScheduleScreen()), + ); + } + Map _knownAssignees() { final known = {}; for (final shift in widget.controller.items) { @@ -544,9 +555,10 @@ class _KindChip extends StatelessWidget { } class _EmptySchedule extends StatelessWidget { - const _EmptySchedule({required this.onAdd}); + const _EmptySchedule({required this.onAdd, required this.onDriverSchedule}); final VoidCallback onAdd; + final VoidCallback onDriverSchedule; @override Widget build(BuildContext context) { @@ -554,10 +566,77 @@ class _EmptySchedule extends StatelessWidget { icon: Icons.event_note_outlined, title: 'No shifts scheduled', body: 'Add the first shift to start building the pit schedule.', - action: FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add_rounded), - label: const Text('Add shift'), + action: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add_rounded), + label: const Text('Add shift'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: onDriverSchedule, + icon: const Icon(Icons.sports_score_outlined), + label: const Text('Driver schedule'), + ), + ], + ), + ); + } +} + +class _DriverScheduleEntry extends StatelessWidget { + const _DriverScheduleEntry({required this.onOpen}); + + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final muted = PitPalette.inkMutedOf(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Material( + color: PitPalette.surfaceOf(context), + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + child: InkWell( + onTap: onOpen, + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(PitPalette.radiusSm), + border: Border.all(color: PitPalette.outlineOf(context)), + ), + child: Row( + children: [ + Icon(Icons.sports_score_outlined, color: muted), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Driver schedule', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + 'Generate a balanced match rotation for drivers, ' + 'operators, technicians, and human players.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: muted), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon(Icons.chevron_right_rounded, color: muted), + ], + ), + ), + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 3207aa6..918fa51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.0+6 +version: 1.4.0+7 environment: sdk: ^3.11.5 diff --git a/test/container_photo_sync_service_test.dart b/test/container_photo_sync_service_test.dart index 1c9b0fd..7ae64d7 100644 --- a/test/container_photo_sync_service_test.dart +++ b/test/container_photo_sync_service_test.dart @@ -4,24 +4,29 @@ import 'package:spectrumpit/src/services/container_photo_sync_service.dart'; void main() { group('containerPhotoDocId', () { - test('slugs a plain location', () { - expect(containerPhotoDocId('Road Case 1'), 'road-case-1'); + test('keeps a readable slug prefix', () { + expect(containerPhotoDocId('Road Case 1'), startsWith('road-case-1-')); }); test('collapses a slash so the doc id stays Firestore-safe', () { - expect(containerPhotoDocId('Cart 1/Drawer 2'), 'cart-1-drawer-2'); + final id = containerPhotoDocId('Cart 1/Drawer 2'); + expect(id, startsWith('cart-1-drawer-2-')); + expect(id, matches(RegExp(r'^[a-z0-9-]+-[0-9a-f]{8}$'))); }); - test('lowercases mixed case', () { - expect(containerPhotoDocId('Road CASE 1'), 'road-case-1'); + test('lowercases mixed case for the prefix', () { + expect(containerPhotoDocId('Road CASE 1'), startsWith('road-case-1-')); }); test('trims leading and trailing whitespace', () { - expect(containerPhotoDocId(' Cart 1 '), 'cart-1'); + expect(containerPhotoDocId(' Cart 1 '), containerPhotoDocId('Cart 1')); }); test('collapses runs of separators to one dash', () { - expect(containerPhotoDocId('Cart 1 / Drawer 2'), 'cart-1-drawer-2'); + expect( + containerPhotoDocId('Cart 1 / Drawer 2'), + startsWith('cart-1-drawer-2-'), + ); }); test('falls back to the placeholder when nothing is left', () { @@ -35,6 +40,17 @@ void main() { containerPhotoDocId('Road Case 1'), containerPhotoDocId('Road Case 1'), ); + expect( + containerPhotoDocId('Drawer A/B'), + containerPhotoDocId('Drawer A/B'), + ); + }); + + test('distinct locations that share a slug do not collide', () { + expect( + containerPhotoDocId('Drawer A/B'), + isNot(containerPhotoDocId('Drawer A B')), + ); }); }); } diff --git a/test/driver_schedule_generator_test.dart b/test/driver_schedule_generator_test.dart new file mode 100644 index 0000000..b6cb97f --- /dev/null +++ b/test/driver_schedule_generator_test.dart @@ -0,0 +1,547 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spectrumpit/src/models/driver_schedule.dart'; +import 'package:spectrumpit/src/services/driver_schedule_generator.dart'; + +int appearances(DriverSchedule schedule, String name, List roleKeys) { + var count = 0; + for (var slot = 0; slot < schedule.slots; slot++) { + for (final roleKey in roleKeys) { + if (schedule.nameAt(roleKey, slot) == name) count++; + } + } + return count; +} + +DriverSchedule fixedSchedule({ + required ScheduleConfig config, + required int slots, + required bool handoff, + required Map> columns, + Map>? rosters, +}) { + return DriverSchedule( + config: config, + slots: slots, + handoff: handoff, + columns: columns, + rosters: + rosters ?? + {for (final entry in columns.entries) entry.key: entry.value.toSet()}, + ); +} + +void main() { + group('parseNameList', () { + test('splits on newlines and commas, trimming blanks', () { + expect(parseNameList(' Alice \n Bob, Cara \n\n , '), [ + 'Alice', + 'Bob', + 'Cara', + ]); + }); + + test('keeps repeats and order, which are how a share is set', () { + expect(parseNameList('Alice\nBob\nAlice'), ['Alice', 'Bob', 'Alice']); + }); + + test('an empty field is an empty list, not a blank name', () { + expect(parseNameList(' \n , '), isEmpty); + }); + }); + + group('validateSlotCount', () { + test('accepts a plain count', () { + expect(validateSlotCount(' 12 '), isNull); + }); + + test('explains a missing or unreadable count', () { + expect(validateSlotCount(''), isNotNull); + expect(validateSlotCount('lots'), isNotNull); + }); + + test('explains a count below one or above the cap', () { + expect(validateSlotCount('0'), isNotNull); + expect(validateSlotCount('${maxScheduleSlots + 1}'), isNotNull); + expect(validateSlotCount('$maxScheduleSlots'), isNull); + }); + }); + + group('generate', () { + test('rejects a slot count outside the supported range', () { + final generator = DriverScheduleGenerator(random: Random(1)); + expect( + () => generator.generate( + singleRobotConfig, + const {}, + slots: 0, + handoff: false, + ), + throwsArgumentError, + ); + expect( + () => generator.generate( + singleRobotConfig, + const {}, + slots: maxScheduleSlots + 1, + handoff: false, + ), + throwsArgumentError, + ); + }); + + test('spreads a role evenly when the slots divide by the names', () { + final schedule = DriverScheduleGenerator(random: Random(3)).generate( + singleRobotConfig, + const { + 'driver': ['Alice', 'Bob', 'Cara'], + }, + slots: 6, + handoff: false, + ); + + for (final name in ['Alice', 'Bob', 'Cara']) { + expect( + appearances(schedule, name, ['driver']), + 2, + reason: '$name should drive twice in six matches', + ); + } + }); + + test('leaves a role nobody is listed for blank rather than crashing', () { + final schedule = DriverScheduleGenerator(random: Random(4)).generate( + singleRobotConfig, + const { + 'driver': ['Alice'], + }, + slots: 3, + handoff: false, + ); + + expect(schedule.nameAt('driver', 0), 'Alice'); + expect(schedule.nameAt('technician', 0), ''); + expect(schedule.nameAt('nosuchrole', 0), ''); + expect(schedule.nameAt('driver', 99), ''); + }); + + test("being on both robots' driver lists grants no extra turns", () { + final schedule = DriverScheduleGenerator(random: Random(5)).generate( + twoRobotConfig, + const { + 'r1driver': ['Alice', 'Bob'], + 'r2driver': ['Alice', 'Cara'], + }, + slots: 6, + handoff: false, + ); + + final driverKeys = ['r1driver', 'r2driver']; + expect(appearances(schedule, 'Alice', driverKeys), 4); + expect(appearances(schedule, 'Bob', driverKeys), 4); + expect(appearances(schedule, 'Cara', driverKeys), 4); + }); + + test("repeating a name inside one list raises that person's share", () { + final schedule = DriverScheduleGenerator(random: Random(6)).generate( + singleRobotConfig, + const { + 'driver': ['Alice', 'Alice', 'Bob'], + }, + slots: 6, + handoff: false, + ); + + expect(appearances(schedule, 'Alice', ['driver']), 4); + expect(appearances(schedule, 'Bob', ['driver']), 2); + }); + + test('two columns sharing one pool never draw the same person at once', () { + for (var seed = 0; seed < 25; seed++) { + final schedule = DriverScheduleGenerator(random: Random(seed)).generate( + twoRobotConfig, + const { + 'r1driver': ['Ada', 'Ben'], + 'r2driver': ['Cleo', 'Dev'], + 'r1operator': ['Eli', 'Fern'], + 'r2operator': ['Gus', 'Hana'], + 'sharedTechnician': ['Ines', 'Jae', 'Kit', 'Lou'], + 'sharedHumanPlayer': ['Mira', 'Nils', 'Opal', 'Pia'], + }, + slots: 6, + handoff: false, + ); + + expect( + ScheduleHighlights.of(schedule).conflicts, + isEmpty, + reason: 'seed $seed put someone in two seats at once', + ); + } + }); + + test("the hand-off makes each match's operator drive the next", () { + final schedule = DriverScheduleGenerator(random: Random(7)).generate( + singleRobotConfig, + const { + 'driver': ['Alice', 'Bob', 'Cara'], + 'operator': ['Alice', 'Bob', 'Cara'], + }, + slots: 6, + handoff: true, + ); + + final highlights = ScheduleHighlights.of(schedule); + expect(highlights.handoffs.length, greaterThanOrEqualTo(3)); + + for (final name in ['Alice', 'Bob', 'Cara']) { + expect( + appearances(schedule, name, ['operator']), + 2, + reason: 'the hand-off changed how often $name operates', + ); + } + }); + }); + + group('ScheduleHighlights', () { + Map> handoffShaped() => { + 'driver': ['Alice', 'Bob'], + 'operator': ['Bob', 'Cara'], + 'technician': ['Dan', 'Eve'], + 'humanPlayer': ['Fay', 'Gus'], + }; + + test('flags the repeat as back-to-back when no hand-off was asked for', () { + final highlights = ScheduleHighlights.of( + fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: false, + columns: handoffShaped(), + ), + ); + + expect(highlights.isBackToBack(1, 'driver'), isTrue); + expect(highlights.isBackToBack(0, 'operator'), isTrue); + expect(highlights.isHandoff(0, 'operator'), isFalse); + }); + + test('excuses the same repeat when the hand-off was asked for', () { + final highlights = ScheduleHighlights.of( + fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: true, + columns: handoffShaped(), + ), + ); + + expect(highlights.isBackToBack(1, 'driver'), isFalse); + expect(highlights.isHandoff(0, 'operator'), isTrue); + expect(highlights.handoffMissed, 0); + }); + + test('marks both cells of a same-slot conflict', () { + final highlights = ScheduleHighlights.of( + fixedSchedule( + config: singleRobotConfig, + slots: 1, + handoff: false, + columns: { + 'driver': ['Alice'], + 'operator': ['Alice'], + 'technician': ['Bob'], + 'humanPlayer': ['Cara'], + }, + ), + ); + + expect(highlights.isConflict(0, 'driver'), isTrue); + expect(highlights.isConflict(0, 'operator'), isTrue); + expect(highlights.isConflict(0, 'technician'), isFalse); + }); + + test('counts a hand-off that could not be arranged', () { + final highlights = ScheduleHighlights.of( + fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: true, + columns: { + 'driver': ['Alice', 'Bob'], + 'operator': ['Cara', 'Dan'], + 'technician': ['Eve', 'Fay'], + 'humanPlayer': ['Gus', 'Hal'], + }, + ), + ); + + expect(highlights.handoffMissed, 1); + expect(highlights.handoffs, isEmpty); + }); + + test('a clean rotation has nothing to explain', () { + final highlights = ScheduleHighlights.of( + fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: false, + columns: { + 'driver': ['Alice', 'Bob'], + 'operator': ['Cara', 'Dan'], + 'technician': ['Eve', 'Fay'], + 'humanPlayer': ['Gus', 'Hal'], + }, + ), + ); + + expect(highlights.isQuiet, isTrue); + }); + }); + + group('AttendanceTable', () { + test('someone on a list who got no slot reads zero, not nothing', () { + final table = AttendanceTable.of( + fixedSchedule( + config: singleRobotConfig, + slots: 1, + handoff: false, + columns: { + 'driver': ['Alice'], + 'operator': ['Bob'], + 'technician': ['Cara'], + 'humanPlayer': ['Dan'], + }, + rosters: { + 'driver': {'Alice', 'Zoe'}, + 'operator': {'Bob'}, + 'technician': {'Cara'}, + 'humanPlayer': {'Dan'}, + }, + ), + singleRobotConfig.effectiveAttendanceViews.single, + ); + + final zoe = table.rows.firstWhere((row) => row.name == 'Zoe'); + expect(zoe.total, 0); + + expect(zoe.counts, [0, null, null, null]); + }); + + test('busiest first, then alphabetical', () { + final table = AttendanceTable.of( + fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: false, + columns: { + 'driver': ['Alice', 'Alice'], + 'operator': ['Bob', 'Cara'], + 'technician': ['', ''], + 'humanPlayer': ['', ''], + }, + rosters: { + 'driver': {'Alice'}, + 'operator': {'Bob', 'Cara'}, + 'technician': {}, + 'humanPlayer': {}, + }, + ), + singleRobotConfig.effectiveAttendanceViews.single, + ); + + expect( + [for (final row in table.rows) row.name], + ['Alice', 'Bob', 'Cara'], + ); + expect(table.rows.first.total, 2); + }); + + test('a person in two roles in one match still attends one match', () { + final table = AttendanceTable.of( + fixedSchedule( + config: singleRobotConfig, + slots: 1, + handoff: false, + columns: { + 'driver': ['Alice'], + 'operator': ['Alice'], + 'technician': ['Bob'], + 'humanPlayer': ['Cara'], + }, + ), + singleRobotConfig.effectiveAttendanceViews.single, + ); + + final alice = table.rows.firstWhere((row) => row.name == 'Alice'); + expect(alice.counts, [1, 1, null, null]); + expect(alice.total, 1); + }); + + test('the both-robots view totals a role across the two robots', () { + final schedule = fixedSchedule( + config: twoRobotConfig, + slots: 2, + handoff: false, + columns: { + 'r1driver': ['Alice', 'Bob'], + 'r1operator': ['Cara', 'Dan'], + 'r1technician': ['Eve', 'Fay'], + 'r1humanPlayer': ['Gus', 'Hal'], + 'r2driver': ['Ida', 'Alice'], + 'r2operator': ['Jan', 'Kai'], + 'r2technician': ['Fay', 'Eve'], + 'r2humanPlayer': ['Hal', 'Gus'], + }, + ); + final views = twoRobotConfig.effectiveAttendanceViews; + + final both = AttendanceTable.of(schedule, views[0]); + final alice = both.rows.firstWhere((row) => row.name == 'Alice'); + + expect(alice.counts.first, 2); + expect(alice.total, 2); + + final robotOne = AttendanceTable.of(schedule, views[1]); + expect( + robotOne.rows.firstWhere((row) => row.name == 'Alice').counts.first, + 1, + ); + final robotTwo = AttendanceTable.of(schedule, views[2]); + expect( + robotTwo.rows.firstWhere((row) => row.name == 'Alice').counts.first, + 1, + ); + }); + }); + + group('scheduleAsTabSeparatedText', () { + test('writes a header and one row per match', () { + final schedule = fixedSchedule( + config: singleRobotConfig, + slots: 2, + handoff: false, + columns: { + 'driver': ['Alice', 'Bob'], + 'operator': ['Cara', 'Dan'], + 'technician': ['Eve', 'Fay'], + 'humanPlayer': ['Gus', 'Hal'], + }, + ); + + expect( + scheduleAsTabSeparatedText( + schedule, + singleRobotConfig.effectiveRenderGroups.single, + ), + '#\tDriver\tOperator\tTechnician\tHuman player\n' + '1\tAlice\tCara\tEve\tGus\n' + '2\tBob\tDan\tFay\tHal', + ); + }); + + test('an unfilled seat reads as a dash', () { + final schedule = fixedSchedule( + config: singleRobotConfig, + slots: 1, + handoff: false, + columns: { + 'driver': ['Alice'], + 'operator': [''], + 'technician': [''], + 'humanPlayer': [''], + }, + ); + + expect( + scheduleAsTabSeparatedText( + schedule, + singleRobotConfig.effectiveRenderGroups.single, + ), + '#\tDriver\tOperator\tTechnician\tHuman player\n1\tAlice\t-\t-\t-', + ); + }); + + test('a two-robot chart exports only its own robot', () { + final schedule = fixedSchedule( + config: twoRobotConfig, + slots: 1, + handoff: false, + columns: { + 'r1driver': ['Alice'], + 'r1operator': ['Bob'], + 'r1technician': ['Cara'], + 'r1humanPlayer': ['Dan'], + 'r2driver': ['Eve'], + 'r2operator': ['Fay'], + 'r2technician': ['Gus'], + 'r2humanPlayer': ['Hal'], + }, + ); + + final text = scheduleAsTabSeparatedText( + schedule, + twoRobotConfig.effectiveRenderGroups[1], + ); + expect(text, contains('Eve')); + expect(text, isNot(contains('Alice'))); + }); + }); + + group('config', () { + test('every source input is a field the crew can actually fill in', () { + for (final config in scheduleConfigs) { + final offered = {for (final input in config.inputs) input.key}; + for (final source in config.effectiveSources) { + for (final input in source.inputs) { + expect( + offered, + contains(input), + reason: '${config.label} draws on "$input" with no field for it', + ); + } + } + } + }); + + test('every role is filled by exactly one source', () { + for (final config in scheduleConfigs) { + final filled = [ + for (final source in config.effectiveSources) ...source.roles, + ]; + expect(filled..sort(), (config.roleKeys.toList()..sort())); + } + }); + + test('charts and attendance views cover every role', () { + for (final config in scheduleConfigs) { + final charted = { + for (final group in config.effectiveRenderGroups) ...group.roleKeys, + }; + expect(charted, config.roleKeys.toSet()); + + for (final view in config.effectiveAttendanceViews) { + final counted = { + for (final column in view.columns) ...column.roleKeys, + }; + expect( + config.roleKeys.toSet().containsAll(counted), + isTrue, + reason: '${view.label} counts a role the config does not have', + ); + } + } + }); + + test('a hand-off pair names two real roles', () { + for (final config in scheduleConfigs) { + for (final pair in config.handoffPairs) { + expect(config.roleKeys, contains(pair.driver)); + expect(config.roleKeys, contains(pair.operator)); + } + } + }); + }); +} diff --git a/test/driver_schedule_screen_test.dart b/test/driver_schedule_screen_test.dart new file mode 100644 index 0000000..202a71c --- /dev/null +++ b/test/driver_schedule_screen_test.dart @@ -0,0 +1,237 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spectrumpit/src/services/driver_schedule_generator.dart'; +import 'package:spectrumpit/src/theme/app_theme.dart'; +import 'package:spectrumpit/src/ui/driver_schedule_screen.dart'; + +void main() { + Future pumpScreen(WidgetTester tester) async { + tester.view.physicalSize = const Size(1100, 3600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + theme: buildDarkAppTheme(), + home: DriverScheduleScreen( + generator: DriverScheduleGenerator(random: Random(11)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future enterNames( + WidgetTester tester, + Map byLabel, + ) async { + for (final entry in byLabel.entries) { + await tester.enterText( + find.widgetWithText(TextField, entry.key), + entry.value, + ); + } + await tester.pump(); + } + + Future generate(WidgetTester tester) async { + await tester.tap(find.widgetWithText(FilledButton, 'Generate')); + await tester.pumpAndSettle(); + } + + Finder chart() => find.byType(Table).first; + + testWidgets('offers a name field per role and a generate action', ( + tester, + ) async { + await pumpScreen(tester); + + expect(find.text('Driver schedule'), findsOneWidget); + for (final role in ['Driver', 'Operator', 'Technician', 'Human player']) { + expect(find.widgetWithText(TextField, role), findsOneWidget); + } + expect(find.widgetWithText(FilledButton, 'Generate'), findsOneWidget); + }); + + testWidgets('two-robot mode asks for the six lists under their headings', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.text('Two robots')); + await tester.pumpAndSettle(); + + expect(find.text('Robot 1'), findsOneWidget); + expect(find.text('Robot 2'), findsOneWidget); + expect(find.text('Shared by both robots'), findsOneWidget); + + expect(find.widgetWithText(TextField, 'Driver'), findsNWidgets(2)); + expect(find.widgetWithText(TextField, 'Operator'), findsNWidgets(2)); + expect(find.widgetWithText(TextField, 'Technician'), findsOneWidget); + }); + + testWidgets('generating draws the chart and the attendance table', ( + tester, + ) async { + await pumpScreen(tester); + await enterNames(tester, { + 'Driver': 'Alice\nBob\nCara', + 'Operator': 'Dan\nEve\nFay', + }); + await generate(tester); + + expect(find.text('Matches per person'), findsOneWidget); + expect( + find.descendant(of: chart(), matching: find.text('Alice')), + findsWidgets, + ); + + expect( + find.descendant(of: chart(), matching: find.text('6')), + findsOneWidget, + ); + }); + + testWidgets('chart name columns are wide enough to read', (tester) async { + await pumpScreen(tester); + await enterNames(tester, {'Driver': 'Alice\nBob\nCara'}); + await generate(tester); + + final cell = find + .ancestor( + of: find.descendant(of: chart(), matching: find.text('Alice')).first, + matching: find.byType(Container), + ) + .first; + expect(tester.getSize(cell).width, greaterThanOrEqualTo(116.0)); + }); + + testWidgets('an unreadable match count explains itself and draws nothing', ( + tester, + ) async { + await pumpScreen(tester); + await enterNames(tester, {'Driver': 'Alice\nBob'}); + await tester.enterText(find.widgetWithText(TextField, 'Matches'), 'lots'); + await generate(tester); + + expect(find.text('Enter how many matches to schedule'), findsOneWidget); + expect(find.text('Matches per person'), findsNothing); + }); + + testWidgets('generating with no names asks for names instead of a table', ( + tester, + ) async { + await pumpScreen(tester); + await generate(tester); + + expect( + find.text('Add at least one person to a role above, then generate.'), + findsOneWidget, + ); + expect(find.text('Matches per person'), findsNothing); + }); + + testWidgets('turning on the hand-off redraws the schedule on screen', ( + tester, + ) async { + await pumpScreen(tester); + await enterNames(tester, { + 'Driver': 'Alice\nBob\nCara', + 'Operator': 'Alice\nBob\nCara', + }); + await generate(tester); + expect(find.text('Matches per person'), findsOneWidget); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(SwitchListTile)).value, + isTrue, + ); + + expect(find.text('Matches per person'), findsOneWidget); + expect(find.text('Operates, then drives next match'), findsOneWidget); + }); + + testWidgets('someone with no slots reads as zero, never as null', ( + tester, + ) async { + await pumpScreen(tester); + await enterNames(tester, {'Driver': 'Alice\nBob\nCara\nDan'}); + await tester.enterText(find.widgetWithText(TextField, 'Matches'), '2'); + await generate(tester); + + expect(find.text('null'), findsNothing); + expect(find.text('not listed'), findsWidgets); + expect(find.text('0'), findsWidgets); + }); + + testWidgets('tapping a person marks their matches in the chart', ( + tester, + ) async { + await pumpScreen(tester); + await enterNames(tester, {'Driver': 'Alice\nBob'}); + await generate(tester); + + bool bordered(Widget widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration! as BoxDecoration).border != null; + + final before = tester.widgetList(find.byWidgetPredicate(bordered)).length; + + await tester.tap( + find + .descendant(of: find.byType(Table).last, matching: find.text('Alice')) + .first, + ); + await tester.pumpAndSettle(); + + final after = tester.widgetList(find.byWidgetPredicate(bordered)).length; + expect(after, greaterThan(before)); + + await tester.tap( + find + .descendant(of: find.byType(Table).last, matching: find.text('Alice')) + .first, + ); + await tester.pumpAndSettle(); + expect(tester.widgetList(find.byWidgetPredicate(bordered)).length, before); + }); + + testWidgets('copy puts the chart on the clipboard as pasteable rows', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await pumpScreen(tester); + await enterNames(tester, {'Driver': 'Alice\nBob'}); + await generate(tester); + await tester.tap(find.widgetWithText(TextButton, 'Copy')); + await tester.pumpAndSettle(); + + expect(copied, isNotNull); + expect(copied!.split('\n').first, startsWith('#\tDriver')); + expect(copied!.split('\n').length, 7); + expect(find.text('Schedule copied'), findsOneWidget); + }); +} diff --git a/test/inventory_controller_test.dart b/test/inventory_controller_test.dart index 133b990..53fd67d 100644 --- a/test/inventory_controller_test.dart +++ b/test/inventory_controller_test.dart @@ -244,4 +244,29 @@ void main() { await blocked; expect(sync.serverOps, ['delete:b', 'upsert:a']); }); + + test('a second consecutive failed write rolls back to the last confirmed ' + 'value, not the prior failed one', () async { + final controller = InventoryController( + authService: FakeSpectrumAuthService(initialUser: _signedInUser), + syncService: sync, + ); + addTearDown(controller.dispose); + await controller.bootstrap(); + + await controller.upsert(_item('a', name: 'A')); + expect(controller.items.single.name, 'A'); + + sync.failSchedule.addAll(['b failed', 'c failed']); + final writingB = controller + .upsert(_item('a', name: 'B')) + .catchError((_) {}); + final writingC = controller + .upsert(_item('a', name: 'C')) + .catchError((_) {}); + await writingB; + await writingC; + + expect(controller.items.single.name, 'A'); + }); } diff --git a/test/packing_tab_test.dart b/test/packing_tab_test.dart index b48c311..4bd8a80 100644 --- a/test/packing_tab_test.dart +++ b/test/packing_tab_test.dart @@ -531,13 +531,41 @@ void main() { expect(controller.items.length, 1); expect(controller.items.single.itemId, 'inv-1'); - expect(controller.items.single.id, isNot('inv-1')); + expect(controller.items.single.id, 'inv-1'); expect(controller.items.single.packingStatus, PackingStatus.packing); expect(sync.upserts, isNotEmpty); expect(find.text('Impact Driver'), findsOneWidget); expect(find.text('Packing'), findsOneWidget); }); + testWidgets( + 'saving a not-started row from the editor uses the item id, not a ' + 'fresh one', + (tester) async { + final controller = await _makeController(); + final inventory = await _makeInventory( + initial: [_inventoryItem('inv-1')], + ); + await tester.pumpWidget( + _wrap( + controller, + inventory, + containerPhotoSyncService: FakeContainerPhotoSyncService(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Impact Driver')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Add item')); + await tester.pumpAndSettle(); + + expect(controller.items.length, 1); + expect(controller.items.single.id, 'inv-1'); + expect(controller.items.single.itemId, 'inv-1'); + }, + ); + testWidgets('items sharing a pitLocation render one location header', ( tester, ) async { @@ -586,6 +614,100 @@ void main() { expect(find.byTooltip('Open the container photo'), findsOneWidget); }); + testWidgets( + 'a failed container photo read shows the offline state and tapping it retries', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final controller = await _makeController(); + final inventory = await _makeInventory( + initial: [_inventoryItem('inv-1')], + ); + final syncService = FakeContainerPhotoSyncService( + readFailure: Exception('offline'), + ); + var pickerOpened = false; + final photoService = fakePhotoService( + picker: (_) async { + pickerOpened = true; + return PickedPhoto(bytes: tinyPng, contentType: 'image/png'); + }, + ); + await tester.pumpWidget( + _wrap( + controller, + inventory, + photoService: photoService, + containerPhotoSyncService: syncService, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.cloud_off_rounded), findsOneWidget); + expect(find.byIcon(Icons.photo_camera_outlined), findsNothing); + expect( + find.byTooltip( + 'Could not check for a container photo -- tap to retry', + ), + findsOneWidget, + ); + expect(syncService.readCalls.length, 1); + + await tester.tap(find.byIcon(Icons.cloud_off_rounded)); + await tester.pumpAndSettle(); + + expect(syncService.readCalls.length, 2); + expect(pickerOpened, isFalse); + expect(syncService.writeCalls, isEmpty); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('replacing an existing container photo deletes the old key', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final controller = await _makeController(); + final inventory = await _makeInventory( + initial: [_inventoryItem('inv-1')], + ); + final stored = {'rc1-db.jpg': tinyPng}; + final syncService = FakeContainerPhotoSyncService( + seed: const {'RC1-DB': 'rc1-db.jpg'}, + ); + await tester.pumpWidget( + _wrap( + controller, + inventory, + photoService: fakePhotoService( + stored: stored, + picker: (_) async => + PickedPhoto(bytes: tinyPng, contentType: 'image/png'), + ), + containerPhotoSyncService: syncService, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Open the container photo')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(OutlinedButton, 'Replace'), findsOneWidget); + await tester.tap(find.widgetWithText(OutlinedButton, 'Replace')); + await tester.pumpAndSettle(); + + expect(syncService.writeCalls.single.location, 'RC1-DB'); + expect(syncService.writeCalls.single.key, isNot('rc1-db.jpg')); + expect(stored.containsKey('rc1-db.jpg'), isFalse); + expect(stored.containsKey('key-0.jpg'), isTrue); + expect(find.byTooltip('Open the container photo'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('an item with an empty pitLocation gets no location header', ( tester, ) async { diff --git a/test/photo_service_test.dart b/test/photo_service_test.dart index b5308d3..7c4bc8a 100644 --- a/test/photo_service_test.dart +++ b/test/photo_service_test.dart @@ -38,6 +38,12 @@ class _SlowWriteDiskCache extends PhotoDiskCache { files.remove(key); completed.add('remove:$key'); } + + @override + Future clear() async { + files.clear(); + completed.add('clear'); + } } void _diskRaceTests() { @@ -80,6 +86,32 @@ void _diskRaceTests() { expect(deleteDone, isTrue); }); + test('clearCache waits for a queued write before wiping', () async { + final disk = _SlowWriteDiskCache(); + final service = fakePhotoService(diskCache: disk); + + disk.gate = Completer(); + final key = await service.upload(_photo()); + + expect(disk.files, isEmpty); + + final cleared = service.clearCache(); + var clearDone = false; + cleared.then((_) => clearDone = true); + await pumpEventQueue(); + expect( + clearDone, + isFalse, + reason: 'clear returned while a write for the same key was pending', + ); + + disk.gate!.complete(); + await cleared; + + expect(disk.completed, ['write:$key', 'clear']); + expect(disk.files, isEmpty, reason: 'the queued write survived the clear'); + }); + test('operations on different keys do not block each other', () async { final disk = _SlowWriteDiskCache(); final service = fakePhotoService(diskCache: disk); diff --git a/test/support/fake_container_photo_sync_service.dart b/test/support/fake_container_photo_sync_service.dart index 38f8b16..631b735 100644 --- a/test/support/fake_container_photo_sync_service.dart +++ b/test/support/fake_container_photo_sync_service.dart @@ -21,10 +21,13 @@ class FakeContainerPhotoSyncService implements ContainerPhotoSyncService { final List<({String location, String key})> writeCalls = []; + final List readCalls = []; + final List clearCalls = []; @override Future readKey(String location) async { + readCalls.add(location); if (_readFailure != null) throw _readFailure; return _readKeyValues[location]; } diff --git a/test/support/fake_inventory_sync_service.dart b/test/support/fake_inventory_sync_service.dart index 11cb922..09f190f 100644 --- a/test/support/fake_inventory_sync_service.dart +++ b/test/support/fake_inventory_sync_service.dart @@ -13,6 +13,8 @@ class FakeInventorySyncService implements InventorySyncService { Object? failWith; + final List failSchedule = []; + Completer? holdUpsert; final List serverOps = []; @@ -20,6 +22,11 @@ class FakeInventorySyncService implements InventorySyncService { Iterable get storedIds => _items.keys; void _throwIfConfigured() { + if (failSchedule.isNotEmpty) { + final failure = failSchedule.removeAt(0); + if (failure != null) throw failure; + return; + } final failure = failWith; if (failure == null) return; failWith = null;