From 9ea0bb22e29c9540a11c51660374a4299e81d09c Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:24 +0100 Subject: [PATCH 01/33] add firebase_storage and file_picker deps - firebase_storage for uploading/downloading hla result documents - file_picker for staff selecting a result file to upload - needed by the hla typing feature --- pubspec.lock | 112 ++++++++++++++++++++++++++++++++++++--------------- pubspec.yaml | 2 + 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 65138b1..87d04af 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 url: "https://pub.dev" source: hosted - version: "1.3.54" + version: "1.3.59" analyzer: dependency: transitive description: @@ -221,26 +221,26 @@ packages: dependency: "direct main" description: name: cloud_firestore - sha256: "89a5e32716794b6a8d0ec1b5dfda988194e92daedaa3f3bed66fa0d0a595252e" + sha256: "2d33da4465bdb81b6685c41b535895065adcb16261beb398f5f3bbc623979e9c" url: "https://pub.dev" source: hosted - version: "5.6.6" + version: "5.6.12" cloud_firestore_platform_interface: dependency: transitive description: name: cloud_firestore_platform_interface - sha256: "9f012844eb59be6827ed97415875c5a29ccacd28bc79bf85b4680738251a33df" + sha256: "413c4e01895cf9cb3de36fa5c219479e06cd4722876274ace5dfc9f13ab2e39b" url: "https://pub.dev" source: hosted - version: "6.6.6" + version: "6.6.12" cloud_firestore_web: dependency: transitive description: name: cloud_firestore_web - sha256: b8b754269be0e907acd9ff63ad60f66b84c78d330ca1d7e474f86c9527ddc803 + sha256: c1e30fc4a0fcedb08723fb4b1f12ee4e56d937cbf9deae1bda43cbb6367bb4cf url: "https://pub.dev" source: hosted - version: "4.4.6" + version: "4.4.12" code_builder: dependency: transitive description: @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -393,94 +401,126 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" firebase_analytics: dependency: "direct main" description: name: firebase_analytics - sha256: "2416b9d864412ab7b571dafded801bbcc7e29b5824623c055002d4d0819bea2b" + sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3" url: "https://pub.dev" source: hosted - version: "11.4.5" + version: "11.6.0" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "3ccf5c876a8bea186016de4bcf53fc1bc6fa01236d740fb501d7ef9be356c58e" + sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845 url: "https://pub.dev" source: hosted - version: "4.3.5" + version: "4.4.3" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: "5e4e3f001b67c2034b76cb2a42a0eed330fb3a8fb41ad13eceb04e8d9a74f662" + sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273 url: "https://pub.dev" source: hosted - version: "0.5.10+11" + version: "0.5.10+16" firebase_auth: dependency: "direct main" description: name: firebase_auth - sha256: "54c62b2d187709114dd09ce658a8803ee91f9119b0e0d3fc2245130ad9bff9ad" + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" url: "https://pub.dev" source: hosted - version: "5.5.2" + version: "5.7.0" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface - sha256: "5402d13f4bb7f29f2fb819f3b6b5a5a56c9f714aef2276546d397e25ac1b6b8e" + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" url: "https://pub.dev" source: hosted - version: "7.6.2" + version: "7.7.3" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: "2be496911f0807895d5fe8067b70b7d758142dd7fb26485cbe23e525e2547764" + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e url: "https://pub.dev" source: hosted - version: "5.14.2" + version: "5.15.3" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.15.2" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: d7253d255ff10f85cfd2adaba9ac17bae878fa3ba577462451163bd9f1d1f0bf + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.3" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "2.24.1" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: f3fa4a17c2f061b16b2e3ac7aaed889ae954b8952d0fd3e0009a9870cde7bbd2 + sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1" url: "https://pub.dev" source: hosted - version: "4.3.5" + version: "4.3.10" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: cedfbe39927711c0e56fc38bfecbd89e17816b21698a3d88d63298c530ed375c + sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb" + url: "https://pub.dev" + source: hosted + version: "3.8.10" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: "958fc88a7ef0b103e694d30beed515c8f9472dde7e8459b029d0e32b8ff03463" url: "https://pub.dev" source: hosted - version: "3.8.5" + version: "12.4.10" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: d2661c05293c2a940c8ea4bc0444e1b5566c79dd3202c2271140c082c8cd8dd4 + url: "https://pub.dev" + source: hosted + version: "5.2.10" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: "629a557c5e1ddb97a3666cbf225e97daa0a66335dbbfdfdce113ef9f881e833f" + url: "https://pub.dev" + source: hosted + version: "3.10.17" fixnum: dependency: transitive description: @@ -590,6 +630,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -1558,5 +1606,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 627fc3a..279e677 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,8 @@ dependencies: flutter_timezone: ^4.1.0 url_launcher: ^6.3.1 health: ^12.0.0 + firebase_storage: ^12.4.10 + file_picker: ^11.0.2 From 0443e13287cde62895477592aa369fdba3a320a4 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:24 +0100 Subject: [PATCH 02/33] add hla and user role enums - HlaTestStatus with label/description/icon per stage - HlaTestStatusX helpers: order, isTerminal, next, isResultsReady, isPatientCancellable - HlaSubjectRelation for cross-match panel members - UserRole (patient/admin) to gate the admin side --- lib/core/utils/enums.dart | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/lib/core/utils/enums.dart b/lib/core/utils/enums.dart index 6aa613a..1f024fd 100644 --- a/lib/core/utils/enums.dart +++ b/lib/core/utils/enums.dart @@ -18,6 +18,133 @@ enum Gender { male, female } enum Genotype { as, ss, aa, unknown } +/// Distinguishes patients from Circle staff. Drives the admin UI gate and is +/// enforced server-side by Firestore rules. Defaults to [patient]; admin +/// accounts are provisioned manually for the MVP (see HLA feature plan §7). +enum UserRole { patient, admin } + +/// The relationship of a sample subject to the requesting patient, used when +/// building the cross-match panel for an HLA typing request. +enum HlaSubjectRelation { + self(label: "Self"), + sibling(label: "Sibling"), + parent(label: "Parent"), + child(label: "Child"), + relative(label: "Relative"), + potentialDonor(label: "Potential donor"), + other(label: "Other"); + + final String label; + const HlaSubjectRelation({required this.label}); +} + +/// The stages of an HLA typing request's journey, from the initial request +/// through to results being delivered. Each value carries a patient-facing +/// [label], a short [description] for the timeline, and an [icon] so chips and +/// the status timeline render consistently (mirrors [MedicationType]). +/// +/// The declaration order is the canonical forward order used to compute +/// "completed vs upcoming" stages and to validate admin transitions. +/// [cancelled] is terminal and sits outside the forward sequence. +enum HlaTestStatus { + requested( + label: "Sample requested", + description: "Your test request has been created.", + icon: FluentIcons.document_add_24_regular, + ), + bookingConfirmed( + label: "Appointment booked", + description: "Your collection appointment is confirmed.", + icon: FluentIcons.calendar_checkmark_24_regular, + ), + collected( + label: "Sample collected", + description: "Your sample has been collected.", + icon: FluentIcons.beaker_24_regular, + ), + sentForTesting( + label: "Sample sent for testing", + description: "Your sample is being prepared for the lab.", + icon: FluentIcons.box_24_regular, + ), + inTransit( + label: "Sample in transit", + description: "Your sample is on its way to the testing lab.", + icon: FluentIcons.vehicle_truck_24_regular, + ), + tested( + label: "Sample tested", + description: "The lab has completed analysis of your sample.", + icon: FluentIcons.flash_24_regular, + ), + resultsProcessing( + label: "Results processing", + description: "Your results are being prepared.", + icon: FluentIcons.hourglass_24_regular, + ), + resultsAvailable( + label: "Results available", + description: "Your results have been uploaded.", + icon: FluentIcons.document_checkmark_24_regular, + ), + resultsSent( + label: "Results sent", + description: "Your results have been delivered to you.", + icon: FluentIcons.checkmark_circle_24_regular, + ), + cancelled( + label: "Cancelled", + description: "This request was cancelled.", + icon: FluentIcons.dismiss_circle_24_regular, + ); + + final String label; + final String description; + final IconData icon; + const HlaTestStatus({ + required this.label, + required this.description, + required this.icon, + }); +} + +/// Helpers for reasoning about the HLA status journey. Kept as an extension so +/// the enum declaration stays declarative. +extension HlaTestStatusX on HlaTestStatus { + /// Position in the canonical forward sequence. [HlaTestStatus.cancelled] is + /// terminal and reported as -1 (it is not part of the forward order). + int get order => + this == HlaTestStatus.cancelled ? -1 : HlaTestStatus.values.indexOf(this); + + /// Terminal states cannot transition further. + bool get isTerminal => + this == HlaTestStatus.cancelled || this == HlaTestStatus.resultsSent; + + /// The next status in the forward sequence, or null at the end / for + /// terminal states. + HlaTestStatus? get next { + if (isTerminal || this == HlaTestStatus.cancelled) return null; + final int nextIndex = order + 1; + // The last forward value is resultsSent; cancelled is excluded. + if (nextIndex >= HlaTestStatus.values.length - 1) { + return HlaTestStatus.resultsSent; + } + return HlaTestStatus.values[nextIndex]; + } + + /// Whether results are ready to view (results uploaded or delivered). + bool get isResultsReady => + !isCancelledState && order >= HlaTestStatus.resultsAvailable.order; + + bool get isCancelledState => this == HlaTestStatus.cancelled; + + /// Whether a patient is still allowed to cancel at this stage (before the + /// sample has been collected). + bool get isPatientCancellable => + this == HlaTestStatus.requested || + this == HlaTestStatus.bookingConfirmed; +} + enum AppListWheelScrollViewPickerMode { integer, duration, time, decimal, text } enum MedicationType { From f9e543711996d6359a846bf9bced4256e419b535 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:24 +0100 Subject: [PATCH 03/33] add role field to user profile - adds nullable UserRole with objectbox dbRole converter (defaults to patient) - threads role through ctor, copyWith, toMap and fromMap - drives the in-app admin ui gate --- .../auth/models/user/user_profile.dart | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/features/auth/models/user/user_profile.dart b/lib/features/auth/models/user/user_profile.dart index 49ff3fb..5be359d 100644 --- a/lib/features/auth/models/user/user_profile.dart +++ b/lib/features/auth/models/user/user_profile.dart @@ -53,6 +53,11 @@ class UserProfile extends Equatable { @Transient() Genotype? genotype; + /// Whether this account is a patient or Circle staff. Stored as an enum but + /// converted to a string for persistence. Defaults to [UserRole.patient]. + @Transient() + UserRole? role; + // Health Info /// User's self-reported pain severity level. int? painSeverity; @@ -112,6 +117,19 @@ class UserProfile extends Equatable { } } + /// Converts the role enum to a string for database persistence. + String? get dbRole => role?.name; + + /// Maps a string to the role enum for database retrieval, defaulting to + /// [UserRole.patient] for legacy records that predate the field. + set dbRole(String? value) { + if (value != null) { + role = UserRole.values.byName(value); + } else { + role = UserRole.patient; + } + } + /// Constructor for creating a [UserProfile] instance. UserProfile({ // Profile Info @@ -124,6 +142,7 @@ class UserProfile extends Equatable { this.email, this.photoUrl, this.phone, + this.role, // Health Info this.crisisFrequency, @@ -153,6 +172,7 @@ class UserProfile extends Equatable { String? email, String? photoUrl, String? phone, + UserRole? role, Genotype? genotype, int? painSeverity, String? crisisFrequency, @@ -178,6 +198,7 @@ class UserProfile extends Equatable { email: email ?? this.email, phone: phone ?? this.phone, photoUrl: photoUrl ?? this.photoUrl, + role: role ?? this.role, painSeverity: painSeverity ?? this.painSeverity, crisisFrequency: crisisFrequency ?? this.crisisFrequency, genotype: genotype ?? this.genotype, @@ -207,6 +228,7 @@ class UserProfile extends Equatable { "photoUrl": photoUrl, "phone": phone, "email": email, + "role": (role ?? UserRole.patient).name, }, "health": { "painSeverity": painSeverity, @@ -236,6 +258,9 @@ class UserProfile extends Equatable { name: data["profile"]["name"] as String, displayName: data["profile"]["displayName"] as String?, age: data["profile"]["age"] as int, + role: data["profile"]["role"] != null + ? UserRole.values.byName(data["profile"]["role"] as String) + : UserRole.patient, painSeverity: data["health"]["painSeverity"] as int?, crisisFrequency: data["health"]["crisisFrequency"] as String?, genotype: Genotype.values.byName(data["health"]["genotype"] as String), @@ -312,6 +337,7 @@ class UserProfile extends Equatable { age, gender, genotype, + role, height, weight, bmi, From 7fa92bf0817cdc815d320066fd1459aaf75d4a2b Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:36 +0100 Subject: [PATCH 04/33] add hla data models - HlaTestRequest objectbox entity with json converters for status, subjects and status history (mirrors the medication model pattern) - HlaSampleSubject and HlaStatusEvent plain models with toMap/fromMap - HlaAppointmentSlot with isAvailable / remainingSeats helpers - HlaRequestFormState to hold the in-progress request form --- .../hla/models/hla_appointment_slot.dart | 88 ++++++ .../hla/models/hla_request_form_state.dart | 52 ++++ .../hla/models/hla_sample_subject.dart | 87 ++++++ lib/features/hla/models/hla_status_event.dart | 60 ++++ lib/features/hla/models/hla_test_request.dart | 259 ++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 lib/features/hla/models/hla_appointment_slot.dart create mode 100644 lib/features/hla/models/hla_request_form_state.dart create mode 100644 lib/features/hla/models/hla_sample_subject.dart create mode 100644 lib/features/hla/models/hla_status_event.dart create mode 100644 lib/features/hla/models/hla_test_request.dart diff --git a/lib/features/hla/models/hla_appointment_slot.dart b/lib/features/hla/models/hla_appointment_slot.dart new file mode 100644 index 0000000..bc9674c --- /dev/null +++ b/lib/features/hla/models/hla_appointment_slot.dart @@ -0,0 +1,88 @@ +import 'package:equatable/equatable.dart'; + +/// A bookable, admin-defined collection slot. Patients pick one of these to +/// come hand over their sample. +/// +/// Lives only in Firestore (`hla_appointment_slots/{id}`) — there is no local +/// ObjectBox copy for the MVP. Booking and cancelling adjust [bookedCount] +/// inside a Firestore transaction so a slot can never be over-booked. +class HlaAppointmentSlot extends Equatable { + /// Firestore document id (a uuid). + final String id; + + /// When the collection happens. + final DateTime dateTime; + + /// Where the patient should come (clinic name / address). + final String location; + + /// Maximum number of bookings this slot can take. + final int capacity; + + /// How many bookings have been taken so far. + final int bookedCount; + + /// Whether the slot is offered to patients. Deactivating hides it without + /// deleting history. + final bool isActive; + + const HlaAppointmentSlot({ + required this.id, + required this.dateTime, + required this.location, + required this.capacity, + this.bookedCount = 0, + this.isActive = true, + }); + + /// A slot is bookable when it is active, has remaining capacity, and is in + /// the future. + bool get isAvailable => + isActive && bookedCount < capacity && dateTime.isAfter(DateTime.now()); + + int get remainingSeats => (capacity - bookedCount).clamp(0, capacity); + + HlaAppointmentSlot copyWith({ + String? id, + DateTime? dateTime, + String? location, + int? capacity, + int? bookedCount, + bool? isActive, + }) { + return HlaAppointmentSlot( + id: id ?? this.id, + dateTime: dateTime ?? this.dateTime, + location: location ?? this.location, + capacity: capacity ?? this.capacity, + bookedCount: bookedCount ?? this.bookedCount, + isActive: isActive ?? this.isActive, + ); + } + + Map toMap() { + return { + "id": id, + "dateTime": dateTime.toUtc().toIso8601String(), + "location": location, + "capacity": capacity, + "bookedCount": bookedCount, + "isActive": isActive, + }; + } + + factory HlaAppointmentSlot.fromMap(Map map) { + return HlaAppointmentSlot( + id: map["id"] as String, + dateTime: DateTime.parse(map["dateTime"] as String), + location: map["location"] as String, + capacity: (map["capacity"] as num).toInt(), + bookedCount: (map["bookedCount"] as num?)?.toInt() ?? 0, + isActive: map["isActive"] as bool? ?? true, + ); + } + + @override + List get props => + [id, dateTime, location, capacity, bookedCount, isActive]; +} diff --git a/lib/features/hla/models/hla_request_form_state.dart b/lib/features/hla/models/hla_request_form_state.dart new file mode 100644 index 0000000..2329fd0 --- /dev/null +++ b/lib/features/hla/models/hla_request_form_state.dart @@ -0,0 +1,52 @@ +import 'package:equatable/equatable.dart'; + +import 'hla_appointment_slot.dart'; +import 'hla_sample_subject.dart'; +import 'hla_test_request.dart'; + +/// In-progress state for building a new HLA request: the panel of subjects, +/// the chosen appointment slot, the consent flag, and (once submitted) the +/// created request. Held by `HlaRequestFormNotifier`. +class HlaRequestFormState extends Equatable { + final List subjects; + final HlaAppointmentSlot? selectedSlot; + final bool consentAccepted; + + /// Set once the request has been successfully created, so the screen can + /// react and navigate. + final HlaTestRequest? submittedRequest; + + const HlaRequestFormState({ + this.subjects = const [], + this.selectedSlot, + this.consentAccepted = false, + this.submittedRequest, + }); + + /// Fresh form seeded with the patient as the primary subject. + factory HlaRequestFormState.initial({required String selfName}) { + return HlaRequestFormState( + subjects: [HlaSampleSubject.self(name: selfName)], + ); + } + + HlaRequestFormState copyWith({ + List? subjects, + HlaAppointmentSlot? selectedSlot, + bool? consentAccepted, + HlaTestRequest? submittedRequest, + }) { + return HlaRequestFormState( + subjects: subjects ?? this.subjects, + selectedSlot: selectedSlot ?? this.selectedSlot, + consentAccepted: consentAccepted ?? this.consentAccepted, + submittedRequest: submittedRequest ?? this.submittedRequest, + ); + } + + bool get isSubmitted => submittedRequest != null; + + @override + List get props => + [subjects, selectedSlot, consentAccepted, submittedRequest]; +} diff --git a/lib/features/hla/models/hla_sample_subject.dart b/lib/features/hla/models/hla_sample_subject.dart new file mode 100644 index 0000000..7f4daab --- /dev/null +++ b/lib/features/hla/models/hla_sample_subject.dart @@ -0,0 +1,87 @@ +import 'package:equatable/equatable.dart'; + +import '../../../core/utils/enums.dart'; + +/// A single person whose sample is collected and cross-matched as part of an +/// HLA typing request — the patient themselves, a relative, or a potential +/// donor. +/// +/// This is a plain Dart model (not an ObjectBox entity). Subjects ride along +/// inside [HlaTestRequest] as a JSON-encoded list, the same way [Dose] and +/// [Frequency] are stored inside `Medication`. +class HlaSampleSubject extends Equatable { + /// The subject's name. Required. + final String name; + + /// Relationship to the requesting patient. + final HlaSubjectRelation relation; + + /// Optional date of birth. + final DateTime? dob; + + /// Optional known genotype. + final Genotype? genotype; + + /// Optional contact phone number. + final String? phone; + + const HlaSampleSubject({ + required this.name, + required this.relation, + this.dob, + this.genotype, + this.phone, + }); + + /// The primary subject is always the patient themselves. + factory HlaSampleSubject.self({String name = "Me", Genotype? genotype}) { + return HlaSampleSubject( + name: name, + relation: HlaSubjectRelation.self, + genotype: genotype, + ); + } + + HlaSampleSubject copyWith({ + String? name, + HlaSubjectRelation? relation, + DateTime? dob, + Genotype? genotype, + String? phone, + }) { + return HlaSampleSubject( + name: name ?? this.name, + relation: relation ?? this.relation, + dob: dob ?? this.dob, + genotype: genotype ?? this.genotype, + phone: phone ?? this.phone, + ); + } + + Map toMap() { + return { + "name": name, + "relation": relation.name, + "dob": dob?.toUtc().toIso8601String(), + "genotype": genotype?.name, + "phone": phone, + }; + } + + factory HlaSampleSubject.fromMap(Map map) { + return HlaSampleSubject( + name: map["name"] as String, + relation: HlaSubjectRelation.values.byName(map["relation"] as String), + dob: map["dob"] != null ? DateTime.parse(map["dob"] as String) : null, + genotype: map["genotype"] != null + ? Genotype.values.byName(map["genotype"] as String) + : null, + phone: map["phone"] as String?, + ); + } + + bool get isSelf => relation == HlaSubjectRelation.self; + + @override + List get props => [name, relation, dob, genotype, phone]; +} diff --git a/lib/features/hla/models/hla_status_event.dart b/lib/features/hla/models/hla_status_event.dart new file mode 100644 index 0000000..18dcb9b --- /dev/null +++ b/lib/features/hla/models/hla_status_event.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +import '../../../core/utils/enums.dart'; + +/// One entry in an HLA request's status history: the [status] reached, the +/// [timestamp] it was reached, and an optional staff [note] explaining the +/// change. The patient's status timeline is built from a list of these. +/// +/// Plain Dart model — stored as a JSON-encoded list inside [HlaTestRequest]. +class HlaStatusEvent extends Equatable { + final HlaTestStatus status; + final DateTime timestamp; + final String? note; + + const HlaStatusEvent({ + required this.status, + required this.timestamp, + this.note, + }); + + /// Convenience constructor stamping the current time. + factory HlaStatusEvent.now({required HlaTestStatus status, String? note}) { + return HlaStatusEvent( + status: status, + timestamp: DateTime.now().toUtc(), + note: note, + ); + } + + HlaStatusEvent copyWith({ + HlaTestStatus? status, + DateTime? timestamp, + String? note, + }) { + return HlaStatusEvent( + status: status ?? this.status, + timestamp: timestamp ?? this.timestamp, + note: note ?? this.note, + ); + } + + Map toMap() { + return { + "status": status.name, + "timestamp": timestamp.toUtc().toIso8601String(), + "note": note, + }; + } + + factory HlaStatusEvent.fromMap(Map map) { + return HlaStatusEvent( + status: HlaTestStatus.values.byName(map["status"] as String), + timestamp: DateTime.parse(map["timestamp"] as String), + note: map["note"] as String?, + ); + } + + @override + List get props => [status, timestamp, note]; +} diff --git a/lib/features/hla/models/hla_test_request.dart b/lib/features/hla/models/hla_test_request.dart new file mode 100644 index 0000000..29fd8e2 --- /dev/null +++ b/lib/features/hla/models/hla_test_request.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:objectbox/objectbox.dart'; + +import '../../../core/utils/enums.dart'; +import 'hla_sample_subject.dart'; +import 'hla_status_event.dart'; + +/// A patient's request for an HLA typing test and the full state of its +/// journey — the panel of sample subjects, the booked collection appointment, +/// the status history, and (once ready) the uploaded results. +/// +/// Persists with ObjectBox. Complex fields (`status`, `subjects`, +/// `statusHistory`) are `@Transient` and stored as strings via the +/// `db*` converters — the same approach `Medication` uses for `Dose` and +/// `Frequency`. Firestore is the realtime source of truth (see plan §D5); the +/// local box is an optional cache. +@Entity() +// ignore: must_be_immutable +class HlaTestRequest extends Equatable { + /// ObjectBox auto-id. + @Id() + int id; + + /// Stable request id (uuid) — also the Firestore document id. + @Unique(onConflict: ConflictStrategy.replace) + final String uid; + + /// The owning patient's Firebase uid. + final String userId; + + /// Patient's display name, denormalized so the admin queue can render + /// without an extra user lookup. + final String patientName; + + /// Current status. Stored as a string via [dbStatus]. + @Transient() + HlaTestStatus status; + + /// The cross-match panel. Stored as a JSON string via [dbSubjects]. + @Transient() + List subjects; + + /// Append-only history of status changes. Stored as JSON via + /// [dbStatusHistory]. + @Transient() + List statusHistory; + + /// Booked appointment fields (null until a slot is chosen). + final String? appointmentSlotId; + @Property(type: PropertyType.date) + final DateTime? appointmentDate; + final String? collectionLocation; + + /// Result fields (null until staff upload them). + final String? resultFileUrl; + final String? resultFileName; + final String? resultSummary; + + /// Admin-only free-text notes. + final String? adminNotes; + + final bool isCancelled; + + @Property(type: PropertyType.date) + final DateTime? createdAt; + @Property(type: PropertyType.date) + final DateTime? updatedAt; + + /// ----- OBJECTBOX TYPE CONVERTERS ----- /// + + String get dbStatus => status.name; + set dbStatus(String value) { + status = HlaTestStatus.values.byName(value); + } + + String get dbSubjects => + jsonEncode(subjects.map((s) => s.toMap()).toList()); + set dbSubjects(String value) { + if (value.isEmpty) { + subjects = []; + return; + } + final List decoded = jsonDecode(value) as List; + subjects = decoded + .map((e) => HlaSampleSubject.fromMap(e as Map)) + .toList(); + } + + String get dbStatusHistory => + jsonEncode(statusHistory.map((e) => e.toMap()).toList()); + set dbStatusHistory(String value) { + if (value.isEmpty) { + statusHistory = []; + return; + } + final List decoded = jsonDecode(value) as List; + statusHistory = decoded + .map((e) => HlaStatusEvent.fromMap(e as Map)) + .toList(); + } + + HlaTestRequest({ + this.id = 0, + required this.uid, + required this.userId, + required this.patientName, + this.status = HlaTestStatus.requested, + List? subjects, + List? statusHistory, + this.appointmentSlotId, + this.appointmentDate, + this.collectionLocation, + this.resultFileUrl, + this.resultFileName, + this.resultSummary, + this.adminNotes, + this.isCancelled = false, + this.createdAt, + this.updatedAt, + }) : subjects = subjects ?? [], + statusHistory = statusHistory ?? []; + + HlaTestRequest copyWith({ + String? userId, + String? patientName, + HlaTestStatus? status, + List? subjects, + List? statusHistory, + String? appointmentSlotId, + DateTime? appointmentDate, + String? collectionLocation, + String? resultFileUrl, + String? resultFileName, + String? resultSummary, + String? adminNotes, + bool? isCancelled, + DateTime? updatedAt, + }) { + return HlaTestRequest( + id: id, + uid: uid, + userId: userId ?? this.userId, + patientName: patientName ?? this.patientName, + status: status ?? this.status, + subjects: subjects ?? this.subjects, + statusHistory: statusHistory ?? this.statusHistory, + appointmentSlotId: appointmentSlotId ?? this.appointmentSlotId, + appointmentDate: appointmentDate ?? this.appointmentDate, + collectionLocation: collectionLocation ?? this.collectionLocation, + resultFileUrl: resultFileUrl ?? this.resultFileUrl, + resultFileName: resultFileName ?? this.resultFileName, + resultSummary: resultSummary ?? this.resultSummary, + adminNotes: adminNotes ?? this.adminNotes, + isCancelled: isCancelled ?? this.isCancelled, + createdAt: createdAt, + updatedAt: updatedAt ?? DateTime.now().toUtc(), + ); + } + + Map toMap() { + return { + "id": uid, + "userId": userId, + "patientName": patientName, + "status": status.name, + "subjects": subjects.map((s) => s.toMap()).toList(), + "statusHistory": statusHistory.map((e) => e.toMap()).toList(), + "appointmentSlotId": appointmentSlotId, + "appointmentDate": appointmentDate?.toUtc().toIso8601String(), + "collectionLocation": collectionLocation, + "resultFileUrl": resultFileUrl, + "resultFileName": resultFileName, + "resultSummary": resultSummary, + "adminNotes": adminNotes, + "isCancelled": isCancelled, + "createdAt": createdAt?.toUtc().toIso8601String(), + "updatedAt": updatedAt?.toUtc().toIso8601String(), + }; + } + + factory HlaTestRequest.fromMap(Map map) { + return HlaTestRequest( + uid: map["id"] as String, + userId: map["userId"] as String, + patientName: (map["patientName"] as String?) ?? "", + status: HlaTestStatus.values.byName(map["status"] as String), + subjects: ((map["subjects"] as List?) ?? []) + .map((e) => HlaSampleSubject.fromMap( + Map.from(e as Map), + )) + .toList(), + statusHistory: ((map["statusHistory"] as List?) ?? []) + .map((e) => HlaStatusEvent.fromMap( + Map.from(e as Map), + )) + .toList(), + appointmentSlotId: map["appointmentSlotId"] as String?, + appointmentDate: map["appointmentDate"] != null + ? DateTime.parse(map["appointmentDate"] as String) + : null, + collectionLocation: map["collectionLocation"] as String?, + resultFileUrl: map["resultFileUrl"] as String?, + resultFileName: map["resultFileName"] as String?, + resultSummary: map["resultSummary"] as String?, + adminNotes: map["adminNotes"] as String?, + isCancelled: map["isCancelled"] as bool? ?? false, + createdAt: map["createdAt"] != null + ? DateTime.parse(map["createdAt"] as String) + : null, + updatedAt: map["updatedAt"] != null + ? DateTime.parse(map["updatedAt"] as String) + : null, + ); + } + + /// Whether results can be viewed by the patient. + bool get hasResults => status.isResultsReady && resultFileUrl != null; + + @Transient() + static HlaTestRequest empty = HlaTestRequest( + uid: "", + userId: "", + patientName: "", + ); + + @Transient() + bool get isEmpty => uid.isEmpty; + + @Transient() + bool get isNotEmpty => uid.isNotEmpty; + + @override + @Transient() + bool? get stringify => true; + + @override + @Transient() + List get props => [ + id, + uid, + userId, + patientName, + status, + subjects, + statusHistory, + appointmentSlotId, + appointmentDate, + collectionLocation, + resultFileUrl, + resultFileName, + resultSummary, + adminNotes, + isCancelled, + createdAt, + updatedAt, + ]; +} From eb41335c4fcac5f529b0818cd4abf1d97d9691b0 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:36 +0100 Subject: [PATCH 05/33] add hla firestore and storage services - remote service: request crud, realtime stream-by-id, admin list, transactional slot book/release, storage result upload - local service: thin objectbox cache for offline request viewing - services only do i/o and throw AppException; no business logic --- .../hla/services/local/hla_local_service.dart | 73 +++++++ .../hla/services/remote/hla_service.dart | 184 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 lib/features/hla/services/local/hla_local_service.dart create mode 100644 lib/features/hla/services/remote/hla_service.dart diff --git a/lib/features/hla/services/local/hla_local_service.dart b/lib/features/hla/services/local/hla_local_service.dart new file mode 100644 index 0000000..0770d1f --- /dev/null +++ b/lib/features/hla/services/local/hla_local_service.dart @@ -0,0 +1,73 @@ +import 'dart:developer'; + +import '../../../../core/error/exceptions.dart'; +import '../../../../objectbox.g.dart'; +import '../../models/hla_test_request.dart'; + +/// Optional ObjectBox cache for HLA requests (plan §D5). Firestore is the +/// realtime source of truth; this lets the requests list render last-known +/// data while offline. Kept deliberately thin for the MVP. +class HlaLocalService { + late final Box _requestBox; + + HlaLocalService({required Store store}) + : _requestBox = store.box(); + + List getCachedRequests() { + try { + return _requestBox.getAll(); + } catch (e, stack) { + log( + "Failed to read cached HLA requests", + error: e, + stackTrace: stack, + name: "HLA Local Service", + ); + throw AppException( + message: "Failed to read cached requests", + debugMessage: e.toString(), + stackTrace: stack, + type: ExceptionType.localStorage, + ); + } + } + + /// Replaces the cache with [requests] (keyed by uid via the unique index). + void cacheRequests(List requests) { + try { + _requestBox.putMany(requests); + } catch (e, stack) { + log( + "Failed to cache HLA requests", + error: e, + stackTrace: stack, + name: "HLA Local Service", + ); + throw AppException( + message: "Failed to cache requests", + debugMessage: e.toString(), + stackTrace: stack, + type: ExceptionType.localStorage, + ); + } + } + + void clear() { + try { + _requestBox.removeAll(); + } catch (e, stack) { + log( + "Failed to clear cached HLA requests", + error: e, + stackTrace: stack, + name: "HLA Local Service", + ); + throw AppException( + message: "Failed to clear cached requests", + debugMessage: e.toString(), + stackTrace: stack, + type: ExceptionType.localStorage, + ); + } + } +} diff --git a/lib/features/hla/services/remote/hla_service.dart b/lib/features/hla/services/remote/hla_service.dart new file mode 100644 index 0000000..a0e8382 --- /dev/null +++ b/lib/features/hla/services/remote/hla_service.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_storage/firebase_storage.dart'; + +import '../../../../core/error/exceptions.dart'; +import '../../../../core/utils/enums.dart'; +import '../../models/hla_appointment_slot.dart'; +import '../../models/hla_test_request.dart'; + +/// Raw Firestore + Firebase Storage I/O for the HLA feature. No business logic +/// lives here (per the architecture doc) — it only reads and writes documents +/// and uploads files, throwing [AppException] for domain conditions and +/// letting [FirebaseException]s propagate to the repository's `futureHandler`. +class HlaService { + final FirebaseFirestore _firestore; + final FirebaseStorage _storage; + + HlaService({FirebaseFirestore? firestore, FirebaseStorage? storage}) + : _firestore = firestore ?? FirebaseFirestore.instance, + _storage = storage ?? FirebaseStorage.instance; + + static const String _requestsCollection = "hla_test_requests"; + static const String _slotsCollection = "hla_appointment_slots"; + + CollectionReference> get _requests => + _firestore.collection(_requestsCollection); + + CollectionReference> get _slots => + _firestore.collection(_slotsCollection); + + // ─── Requests ────────────────────────────────────────────────────────────── + + /// Creates the request document under its uid. + Future createRequest(HlaTestRequest request) async { + await _requests.doc(request.uid).set(request.toMap()); + return request; + } + + /// All requests owned by [userId], newest first. + Future> getRequestsForUser(String userId) async { + final QuerySnapshot> snapshot = await _requests + .where("userId", isEqualTo: userId) + .orderBy("createdAt", descending: true) + .get(); + return snapshot.docs.map((doc) => HlaTestRequest.fromMap(doc.data())).toList(); + } + + /// A single request by id, or throws if it does not exist. + Future getRequest(String requestId) async { + final DocumentSnapshot> snapshot = + await _requests.doc(requestId).get(); + final data = snapshot.data(); + if (!snapshot.exists || data == null) { + throw AppException( + message: "This request could not be found", + type: ExceptionType.firebase, + stackTrace: StackTrace.current, + ); + } + return HlaTestRequest.fromMap(data); + } + + /// Realtime stream of a single request — drives the patient status timeline. + /// Emits null while the document does not (yet) exist. + Stream streamRequest(String requestId) { + return _requests.doc(requestId).snapshots().map((snapshot) { + final data = snapshot.data(); + if (!snapshot.exists || data == null) return null; + return HlaTestRequest.fromMap(data); + }); + } + + /// Persists an existing request (full overwrite via merge). + Future updateRequest(HlaTestRequest request) async { + await _requests.doc(request.uid).set(request.toMap(), SetOptions(merge: true)); + } + + /// Admin: all requests, optionally filtered by [status], newest first. + Future> getAllRequests({HlaTestStatus? status}) async { + Query> query = + _requests.orderBy("createdAt", descending: true); + if (status != null) { + query = _requests + .where("status", isEqualTo: status.name) + .orderBy("createdAt", descending: true); + } + final QuerySnapshot> snapshot = await query.get(); + return snapshot.docs.map((doc) => HlaTestRequest.fromMap(doc.data())).toList(); + } + + // ─── Appointment slots ─────────────────────────────────────────────────────── + + /// Future, active slots with remaining capacity, soonest first. + Future> getAvailableSlots() async { + final QuerySnapshot> snapshot = await _slots + .where("isActive", isEqualTo: true) + .orderBy("dateTime") + .get(); + return snapshot.docs + .map((doc) => HlaAppointmentSlot.fromMap(doc.data())) + .where((slot) => slot.isAvailable) + .toList(); + } + + /// Admin: every slot regardless of state, soonest first. + Future> getAllSlots() async { + final QuerySnapshot> snapshot = + await _slots.orderBy("dateTime").get(); + return snapshot.docs + .map((doc) => HlaAppointmentSlot.fromMap(doc.data())) + .toList(); + } + + /// Admin: create or update a slot. + Future putSlot(HlaAppointmentSlot slot) async { + await _slots.doc(slot.id).set(slot.toMap(), SetOptions(merge: true)); + } + + /// Atomically books [slotId] and persists the already-updated [request] + /// (status `bookingConfirmed`, appointment fields set) in one transaction so + /// a slot can never be over-booked. Throws if the slot filled in the + /// meantime (AC US-3.3). + Future bookSlot({ + required String slotId, + required HlaTestRequest request, + }) async { + final DocumentReference> slotRef = _slots.doc(slotId); + final DocumentReference> requestRef = + _requests.doc(request.uid); + + await _firestore.runTransaction((txn) async { + final DocumentSnapshot> slotSnap = + await txn.get(slotRef); + final data = slotSnap.data(); + if (!slotSnap.exists || data == null) { + throw AppException( + message: "This slot is no longer available", + type: ExceptionType.firebase, + stackTrace: StackTrace.current, + ); + } + final HlaAppointmentSlot slot = HlaAppointmentSlot.fromMap(data); + if (!slot.isAvailable) { + throw AppException( + message: "This slot is no longer available, please pick another", + type: ExceptionType.firebase, + stackTrace: StackTrace.current, + ); + } + txn.update(slotRef, {"bookedCount": slot.bookedCount + 1}); + txn.set(requestRef, request.toMap(), SetOptions(merge: true)); + }); + } + + /// Frees a previously booked seat (on cancellation). Best-effort: clamps at + /// zero and ignores a missing slot. + Future releaseSlot(String slotId) async { + final DocumentReference> slotRef = _slots.doc(slotId); + await _firestore.runTransaction((txn) async { + final DocumentSnapshot> slotSnap = + await txn.get(slotRef); + final data = slotSnap.data(); + if (!slotSnap.exists || data == null) return; + final int current = (data["bookedCount"] as num?)?.toInt() ?? 0; + txn.update(slotRef, {"bookedCount": current > 0 ? current - 1 : 0}); + }); + } + + // ─── Results (Firebase Storage) ────────────────────────────────────────────── + + /// Uploads a result document to `hla_results/{requestId}/{fileName}` and + /// returns the download URL. + Future uploadResultFile({ + required String requestId, + required String filePath, + required String fileName, + }) async { + final Reference ref = + _storage.ref().child("hla_results/$requestId/$fileName"); + await ref.putFile(File(filePath)); + return ref.getDownloadURL(); + } +} From f0f8e617d00dd3abb6ea64c22a5e548cbd8247c6 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:36 +0100 Subject: [PATCH 06/33] add hla repository - abstract interface + impl returning FutureEither via futureHandler - enforces business rules: one active request, valid patient transitions, cancel frees the slot - always includes the patient as the primary panel subject --- .../hla/repositories/hla_repository.dart | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 lib/features/hla/repositories/hla_repository.dart diff --git a/lib/features/hla/repositories/hla_repository.dart b/lib/features/hla/repositories/hla_repository.dart new file mode 100644 index 0000000..dced844 --- /dev/null +++ b/lib/features/hla/repositories/hla_repository.dart @@ -0,0 +1,321 @@ +import 'package:uuid/uuid.dart'; + +import '../../../core/core.dart'; +import '../../../core/error/exceptions.dart'; +import '../../auth/auth.dart'; +import '../models/hla_appointment_slot.dart'; +import '../models/hla_sample_subject.dart'; +import '../models/hla_status_event.dart'; +import '../models/hla_test_request.dart'; +import '../services/local/hla_local_service.dart'; +import '../services/remote/hla_service.dart'; + +/// Bridges the HLA notifiers and the [HlaService] / [HlaLocalService]. Owns the +/// feature's business rules (one active request per patient, valid status +/// transitions, freeing a slot on cancellation) and converts thrown exceptions +/// into [Failure]s via `futureHandler`, returning [FutureEither]. +abstract class HlaRepository { + // Patient + FutureEither createRequest({ + required AppUser user, + required List subjects, + bool consentAccepted, + }); + FutureEither> getRequestsForUser({ + required String userId, + }); + FutureEither getActiveRequest({required String userId}); + FutureEither updateSubjects({ + required HlaTestRequest request, + required List subjects, + }); + FutureEither bookAppointment({ + required HlaTestRequest request, + required HlaAppointmentSlot slot, + }); + FutureEither cancelRequest({required HlaTestRequest request}); + FutureEither> getAvailableSlots(); + + /// Realtime stream of a single request (status timeline). Not wrapped in + /// [FutureEither] — stream errors surface through the notifier's + /// `AsyncValue.error`. + Stream streamRequest({required String requestId}); + + // Admin + FutureEither> getAllRequests({HlaTestStatus? status}); + FutureEither advanceStatus({ + required HlaTestRequest request, + required HlaTestStatus status, + String? note, + }); + FutureEither uploadResult({ + required HlaTestRequest request, + required String filePath, + required String fileName, + String? summary, + }); + FutureEither> getAllSlots(); + FutureEither putSlot({required HlaAppointmentSlot slot}); +} + +class HlaRepositoryImpl implements HlaRepository { + final HlaService _hlaService; + final HlaLocalService? _hlaLocalService; + final Uuid _uuid; + + HlaRepositoryImpl({ + required HlaService hlaService, + HlaLocalService? hlaLocalService, + Uuid? uuid, + }) : _hlaService = hlaService, + _hlaLocalService = hlaLocalService, + _uuid = uuid ?? const Uuid(); + + // ─── Patient ───────────────────────────────────────────────────────────────── + + @override + FutureEither createRequest({ + required AppUser user, + required List subjects, + bool consentAccepted = false, + }) { + return futureHandler(() async { + // D6: only one open journey at a time. + final List existing = + await _hlaService.getRequestsForUser(user.uid); + final bool hasActive = + existing.any((r) => !r.isCancelled && !r.status.isTerminal); + if (hasActive) { + throw AppException( + message: "You already have an active request in progress", + type: ExceptionType.generic, + stackTrace: StackTrace.current, + ); + } + + // The patient is always the primary subject. + final List panel = [ + if (!subjects.any((s) => s.isSelf)) + HlaSampleSubject.self(name: user.getDisplayName()), + ...subjects, + ]; + + final DateTime now = DateTime.now().toUtc(); + final HlaTestRequest request = HlaTestRequest( + uid: _uuid.v4(), + userId: user.uid, + patientName: user.getDisplayName(), + status: HlaTestStatus.requested, + subjects: panel, + statusHistory: [ + HlaStatusEvent(status: HlaTestStatus.requested, timestamp: now), + ], + createdAt: now, + updatedAt: now, + ); + + final HlaTestRequest created = await _hlaService.createRequest(request); + _cache([created, ...existing]); + return created; + }); + } + + @override + FutureEither> getRequestsForUser({ + required String userId, + }) { + return futureHandler(() async { + final List requests = + await _hlaService.getRequestsForUser(userId); + _cache(requests); + return requests; + }); + } + + @override + FutureEither getActiveRequest({required String userId}) { + return futureHandler(() async { + final List requests = + await _hlaService.getRequestsForUser(userId); + try { + return requests + .firstWhere((r) => !r.isCancelled && !r.status.isTerminal); + } catch (_) { + return null; + } + }); + } + + @override + FutureEither updateSubjects({ + required HlaTestRequest request, + required List subjects, + }) { + return futureHandler(() async { + if (request.status != HlaTestStatus.requested) { + throw AppException( + message: "Subjects can only be changed before the sample is collected", + type: ExceptionType.generic, + stackTrace: StackTrace.current, + ); + } + // AC US-2.4: self can never be removed. + final List panel = [ + if (!subjects.any((s) => s.isSelf)) + HlaSampleSubject.self(name: request.patientName), + ...subjects, + ]; + final HlaTestRequest updated = request.copyWith(subjects: panel); + await _hlaService.updateRequest(updated); + return updated; + }); + } + + @override + FutureEither bookAppointment({ + required HlaTestRequest request, + required HlaAppointmentSlot slot, + }) { + return futureHandler(() async { + if (!slot.isAvailable) { + throw AppException( + message: "This slot is no longer available, please pick another", + type: ExceptionType.generic, + stackTrace: StackTrace.current, + ); + } + final HlaTestRequest updated = request.copyWith( + status: HlaTestStatus.bookingConfirmed, + appointmentSlotId: slot.id, + appointmentDate: slot.dateTime, + collectionLocation: slot.location, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: HlaTestStatus.bookingConfirmed), + ], + ); + // Atomic: increments the slot's bookedCount and writes the request. + await _hlaService.bookSlot(slotId: slot.id, request: updated); + return updated; + }); + } + + @override + FutureEither cancelRequest({required HlaTestRequest request}) { + return futureHandler(() async { + if (!request.status.isPatientCancellable) { + throw AppException( + message: "This request can no longer be cancelled", + type: ExceptionType.generic, + stackTrace: StackTrace.current, + ); + } + final HlaTestRequest cancelled = request.copyWith( + status: HlaTestStatus.cancelled, + isCancelled: true, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: HlaTestStatus.cancelled), + ], + ); + await _hlaService.updateRequest(cancelled); + if (request.appointmentSlotId != null) { + await _hlaService.releaseSlot(request.appointmentSlotId!); + } + }); + } + + @override + FutureEither> getAvailableSlots() { + return futureHandler(() => _hlaService.getAvailableSlots()); + } + + @override + Stream streamRequest({required String requestId}) { + return _hlaService.streamRequest(requestId); + } + + // ─── Admin ─────────────────────────────────────────────────────────────────── + + @override + FutureEither> getAllRequests({HlaTestStatus? status}) { + return futureHandler(() => _hlaService.getAllRequests(status: status)); + } + + @override + FutureEither advanceStatus({ + required HlaTestRequest request, + required HlaTestStatus status, + String? note, + }) { + return futureHandler(() async { + final HlaTestRequest updated = request.copyWith( + status: status, + isCancelled: status == HlaTestStatus.cancelled ? true : null, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: status, note: note), + ], + ); + await _hlaService.updateRequest(updated); + // Free the seat if an admin cancels a booked request. + if (status == HlaTestStatus.cancelled && + request.appointmentSlotId != null) { + await _hlaService.releaseSlot(request.appointmentSlotId!); + } + return updated; + }); + } + + @override + FutureEither uploadResult({ + required HlaTestRequest request, + required String filePath, + required String fileName, + String? summary, + }) { + return futureHandler(() async { + final String url = await _hlaService.uploadResultFile( + requestId: request.uid, + filePath: filePath, + fileName: fileName, + ); + final HlaTestRequest updated = request.copyWith( + status: HlaTestStatus.resultsAvailable, + resultFileUrl: url, + resultFileName: fileName, + resultSummary: summary, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: HlaTestStatus.resultsAvailable), + ], + ); + await _hlaService.updateRequest(updated); + return updated; + }); + } + + @override + FutureEither> getAllSlots() { + return futureHandler(() => _hlaService.getAllSlots()); + } + + @override + FutureEither putSlot({required HlaAppointmentSlot slot}) { + return futureHandler(() async { + final HlaAppointmentSlot toSave = + slot.id.isEmpty ? slot.copyWith(id: _uuid.v4()) : slot; + await _hlaService.putSlot(toSave); + return toSave; + }); + } + + /// Best-effort local cache write — never fails a remote read. + void _cache(List requests) { + try { + _hlaLocalService?.cacheRequests(requests); + } catch (_) { + // Caching is opportunistic; ignore failures. + } + } +} From 265af9c80e6faa2cf44b3de791eae391c4a9e814 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:51 +0100 Subject: [PATCH 07/33] add hla riverpod notifiers - requests notifier (+ realtime active-request stream provider), request-form notifier, slots notifier, admin notifier (requests + slots) - follows the existing global-singleton + build-injection pattern for consistency - applies audit fixes for this feature: no pii/state logging, no stackTrace force-unwrap --- .../hla/providers/hla_admin_notifier.dart | 184 +++++++++ .../hla/providers/hla_admin_notifier.g.dart | 348 ++++++++++++++++++ .../providers/hla_request_form_notifier.dart | 93 +++++ .../hla_request_form_notifier.g.dart | 200 ++++++++++ .../hla/providers/hla_requests_notifier.dart | 137 +++++++ .../providers/hla_requests_notifier.g.dart | 184 +++++++++ .../hla/providers/hla_slots_notifier.dart | 51 +++ .../hla/providers/hla_slots_notifier.g.dart | 192 ++++++++++ 8 files changed, 1389 insertions(+) create mode 100644 lib/features/hla/providers/hla_admin_notifier.dart create mode 100644 lib/features/hla/providers/hla_admin_notifier.g.dart create mode 100644 lib/features/hla/providers/hla_request_form_notifier.dart create mode 100644 lib/features/hla/providers/hla_request_form_notifier.g.dart create mode 100644 lib/features/hla/providers/hla_requests_notifier.dart create mode 100644 lib/features/hla/providers/hla_requests_notifier.g.dart create mode 100644 lib/features/hla/providers/hla_slots_notifier.dart create mode 100644 lib/features/hla/providers/hla_slots_notifier.g.dart diff --git a/lib/features/hla/providers/hla_admin_notifier.dart b/lib/features/hla/providers/hla_admin_notifier.dart new file mode 100644 index 0000000..3eeec1f --- /dev/null +++ b/lib/features/hla/providers/hla_admin_notifier.dart @@ -0,0 +1,184 @@ +import 'dart:developer'; + +import 'package:fpdart/fpdart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/core.dart'; +import '../hla.dart'; + +part 'hla_admin_notifier.g.dart'; + +final HlaAdminNotifierProvider hlaAdminNotifierProviderImpl = + hlaAdminNotifierProvider(hlaRepository: hlaRepository); + +final HlaAdminSlotsNotifierProvider hlaAdminSlotsNotifierProviderImpl = + hlaAdminSlotsNotifierProvider(hlaRepository: hlaRepository); + +/// Admin: the full request queue, with actions to advance status and upload +/// results. Visible only to `role == admin` users (UI gate + Firestore rules). +@Riverpod(keepAlive: true) +class HlaAdminNotifier extends _$HlaAdminNotifier { + late final HlaRepository _hlaRepository; + HlaTestStatus? _filter; + + @override + FutureOr> build({ + required HlaRepository hlaRepository, + }) async { + _hlaRepository = hlaRepository; + return []; + } + + Future getAllRequests({HlaTestStatus? status}) async { + _filter = status; + log("Getting all requests (filter: ${status?.name ?? 'none'})", + name: "HLA Admin Notifier"); + state = const AsyncValue.loading(); + final Either> response = + await _hlaRepository.getAllRequests(status: status); + response.fold( + (failure) { + log( + "Failed to get requests: ${failure.message}, code: ${failure.code}", + name: "HLA Admin Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (requests) { + log("Loaded ${requests.length} request(s)", + name: "HLA Admin Notifier"); + state = AsyncValue.data(requests); + }, + ); + } + + Future advanceStatus({ + required HlaTestRequest request, + required HlaTestStatus status, + String? note, + }) async { + log("Advancing ${request.uid} -> ${status.name}", + name: "HLA Admin Notifier"); + state = const AsyncValue.loading(); + final Either response = await _hlaRepository + .advanceStatus(request: request, status: status, note: note); + await response.fold( + (failure) async { + log( + "Failed to advance: ${failure.message}, code: ${failure.code}", + name: "HLA Admin Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (_) async { + log("Advanced ${request.uid}", name: "HLA Admin Notifier"); + await getAllRequests(status: _filter); + }, + ); + } + + Future uploadResult({ + required HlaTestRequest request, + required String filePath, + required String fileName, + String? summary, + }) async { + log("Uploading result for ${request.uid}", name: "HLA Admin Notifier"); + state = const AsyncValue.loading(); + final Either response = + await _hlaRepository.uploadResult( + request: request, + filePath: filePath, + fileName: fileName, + summary: summary, + ); + await response.fold( + (failure) async { + log( + "Failed to upload result: ${failure.message}, code: ${failure.code}", + name: "HLA Admin Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (_) async { + log("Uploaded result for ${request.uid}", name: "HLA Admin Notifier"); + await getAllRequests(status: _filter); + }, + ); + } +} + +/// Admin: appointment slot management (create/update/deactivate). +@Riverpod(keepAlive: true) +class HlaAdminSlotsNotifier extends _$HlaAdminSlotsNotifier { + late final HlaRepository _hlaRepository; + + @override + FutureOr> build({ + required HlaRepository hlaRepository, + }) async { + _hlaRepository = hlaRepository; + return []; + } + + Future getAllSlots() async { + log("Getting all slots", name: "HLA Admin Slots Notifier"); + state = const AsyncValue.loading(); + final Either> response = + await _hlaRepository.getAllSlots(); + response.fold( + (failure) { + log( + "Failed to get slots: ${failure.message}, code: ${failure.code}", + name: "HLA Admin Slots Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (slots) { + log("Loaded ${slots.length} slot(s)", + name: "HLA Admin Slots Notifier"); + state = AsyncValue.data(slots); + }, + ); + } + + Future putSlot({required HlaAppointmentSlot slot}) async { + log("Saving slot", name: "HLA Admin Slots Notifier"); + state = const AsyncValue.loading(); + final Either response = + await _hlaRepository.putSlot(slot: slot); + await response.fold( + (failure) async { + log( + "Failed to save slot: ${failure.message}, code: ${failure.code}", + name: "HLA Admin Slots Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (_) async { + log("Saved slot", name: "HLA Admin Slots Notifier"); + await getAllSlots(); + }, + ); + } +} diff --git a/lib/features/hla/providers/hla_admin_notifier.g.dart b/lib/features/hla/providers/hla_admin_notifier.g.dart new file mode 100644 index 0000000..8fe53d2 --- /dev/null +++ b/lib/features/hla/providers/hla_admin_notifier.g.dart @@ -0,0 +1,348 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hla_admin_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hlaAdminNotifierHash() => r'a07b5731f4ead5b0fb1ba1c3dffec889871e4e74'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$HlaAdminNotifier + extends BuildlessAsyncNotifier> { + late final HlaRepository hlaRepository; + + FutureOr> build({required HlaRepository hlaRepository}); +} + +/// Admin: the full request queue, with actions to advance status and upload +/// results. Visible only to `role == admin` users (UI gate + Firestore rules). +/// +/// Copied from [HlaAdminNotifier]. +@ProviderFor(HlaAdminNotifier) +const hlaAdminNotifierProvider = HlaAdminNotifierFamily(); + +/// Admin: the full request queue, with actions to advance status and upload +/// results. Visible only to `role == admin` users (UI gate + Firestore rules). +/// +/// Copied from [HlaAdminNotifier]. +class HlaAdminNotifierFamily extends Family>> { + /// Admin: the full request queue, with actions to advance status and upload + /// results. Visible only to `role == admin` users (UI gate + Firestore rules). + /// + /// Copied from [HlaAdminNotifier]. + const HlaAdminNotifierFamily(); + + /// Admin: the full request queue, with actions to advance status and upload + /// results. Visible only to `role == admin` users (UI gate + Firestore rules). + /// + /// Copied from [HlaAdminNotifier]. + HlaAdminNotifierProvider call({required HlaRepository hlaRepository}) { + return HlaAdminNotifierProvider(hlaRepository: hlaRepository); + } + + @override + HlaAdminNotifierProvider getProviderOverride( + covariant HlaAdminNotifierProvider provider, + ) { + return call(hlaRepository: provider.hlaRepository); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hlaAdminNotifierProvider'; +} + +/// Admin: the full request queue, with actions to advance status and upload +/// results. Visible only to `role == admin` users (UI gate + Firestore rules). +/// +/// Copied from [HlaAdminNotifier]. +class HlaAdminNotifierProvider + extends AsyncNotifierProviderImpl> { + /// Admin: the full request queue, with actions to advance status and upload + /// results. Visible only to `role == admin` users (UI gate + Firestore rules). + /// + /// Copied from [HlaAdminNotifier]. + HlaAdminNotifierProvider({required HlaRepository hlaRepository}) + : this._internal( + () => HlaAdminNotifier()..hlaRepository = hlaRepository, + from: hlaAdminNotifierProvider, + name: r'hlaAdminNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hlaAdminNotifierHash, + dependencies: HlaAdminNotifierFamily._dependencies, + allTransitiveDependencies: + HlaAdminNotifierFamily._allTransitiveDependencies, + hlaRepository: hlaRepository, + ); + + HlaAdminNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.hlaRepository, + }) : super.internal(); + + final HlaRepository hlaRepository; + + @override + FutureOr> runNotifierBuild( + covariant HlaAdminNotifier notifier, + ) { + return notifier.build(hlaRepository: hlaRepository); + } + + @override + Override overrideWith(HlaAdminNotifier Function() create) { + return ProviderOverride( + origin: this, + override: HlaAdminNotifierProvider._internal( + () => create()..hlaRepository = hlaRepository, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + hlaRepository: hlaRepository, + ), + ); + } + + @override + AsyncNotifierProviderElement> + createElement() { + return _HlaAdminNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HlaAdminNotifierProvider && + other.hlaRepository == hlaRepository; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, hlaRepository.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin HlaAdminNotifierRef on AsyncNotifierProviderRef> { + /// The parameter `hlaRepository` of this provider. + HlaRepository get hlaRepository; +} + +class _HlaAdminNotifierProviderElement + extends AsyncNotifierProviderElement> + with HlaAdminNotifierRef { + _HlaAdminNotifierProviderElement(super.provider); + + @override + HlaRepository get hlaRepository => + (origin as HlaAdminNotifierProvider).hlaRepository; +} + +String _$hlaAdminSlotsNotifierHash() => + r'ec8a6a5d277acd6fe1e38fe2bb1c13f9b5e8e1fc'; + +abstract class _$HlaAdminSlotsNotifier + extends BuildlessAsyncNotifier> { + late final HlaRepository hlaRepository; + + FutureOr> build({ + required HlaRepository hlaRepository, + }); +} + +/// Admin: appointment slot management (create/update/deactivate). +/// +/// Copied from [HlaAdminSlotsNotifier]. +@ProviderFor(HlaAdminSlotsNotifier) +const hlaAdminSlotsNotifierProvider = HlaAdminSlotsNotifierFamily(); + +/// Admin: appointment slot management (create/update/deactivate). +/// +/// Copied from [HlaAdminSlotsNotifier]. +class HlaAdminSlotsNotifierFamily + extends Family>> { + /// Admin: appointment slot management (create/update/deactivate). + /// + /// Copied from [HlaAdminSlotsNotifier]. + const HlaAdminSlotsNotifierFamily(); + + /// Admin: appointment slot management (create/update/deactivate). + /// + /// Copied from [HlaAdminSlotsNotifier]. + HlaAdminSlotsNotifierProvider call({required HlaRepository hlaRepository}) { + return HlaAdminSlotsNotifierProvider(hlaRepository: hlaRepository); + } + + @override + HlaAdminSlotsNotifierProvider getProviderOverride( + covariant HlaAdminSlotsNotifierProvider provider, + ) { + return call(hlaRepository: provider.hlaRepository); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hlaAdminSlotsNotifierProvider'; +} + +/// Admin: appointment slot management (create/update/deactivate). +/// +/// Copied from [HlaAdminSlotsNotifier]. +class HlaAdminSlotsNotifierProvider + extends + AsyncNotifierProviderImpl< + HlaAdminSlotsNotifier, + List + > { + /// Admin: appointment slot management (create/update/deactivate). + /// + /// Copied from [HlaAdminSlotsNotifier]. + HlaAdminSlotsNotifierProvider({required HlaRepository hlaRepository}) + : this._internal( + () => HlaAdminSlotsNotifier()..hlaRepository = hlaRepository, + from: hlaAdminSlotsNotifierProvider, + name: r'hlaAdminSlotsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hlaAdminSlotsNotifierHash, + dependencies: HlaAdminSlotsNotifierFamily._dependencies, + allTransitiveDependencies: + HlaAdminSlotsNotifierFamily._allTransitiveDependencies, + hlaRepository: hlaRepository, + ); + + HlaAdminSlotsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.hlaRepository, + }) : super.internal(); + + final HlaRepository hlaRepository; + + @override + FutureOr> runNotifierBuild( + covariant HlaAdminSlotsNotifier notifier, + ) { + return notifier.build(hlaRepository: hlaRepository); + } + + @override + Override overrideWith(HlaAdminSlotsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: HlaAdminSlotsNotifierProvider._internal( + () => create()..hlaRepository = hlaRepository, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + hlaRepository: hlaRepository, + ), + ); + } + + @override + AsyncNotifierProviderElement> + createElement() { + return _HlaAdminSlotsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HlaAdminSlotsNotifierProvider && + other.hlaRepository == hlaRepository; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, hlaRepository.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin HlaAdminSlotsNotifierRef + on AsyncNotifierProviderRef> { + /// The parameter `hlaRepository` of this provider. + HlaRepository get hlaRepository; +} + +class _HlaAdminSlotsNotifierProviderElement + extends + AsyncNotifierProviderElement< + HlaAdminSlotsNotifier, + List + > + with HlaAdminSlotsNotifierRef { + _HlaAdminSlotsNotifierProviderElement(super.provider); + + @override + HlaRepository get hlaRepository => + (origin as HlaAdminSlotsNotifierProvider).hlaRepository; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/hla/providers/hla_request_form_notifier.dart b/lib/features/hla/providers/hla_request_form_notifier.dart new file mode 100644 index 0000000..66bce70 --- /dev/null +++ b/lib/features/hla/providers/hla_request_form_notifier.dart @@ -0,0 +1,93 @@ +import 'dart:developer'; + +import 'package:fpdart/fpdart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/core.dart'; +import '../../auth/auth.dart'; +import '../hla.dart'; + +part 'hla_request_form_notifier.g.dart'; + +final HlaRequestFormNotifierProvider hlaRequestFormNotifierProviderImpl = + hlaRequestFormNotifierProvider(hlaRepository: hlaRepository); + +/// Holds the in-progress new request the patient is building (subjects + slot +/// + consent) and submits it. The self subject is always present and cannot be +/// removed (AC US-2.1, US-2.4). +@Riverpod(keepAlive: true) +class HlaRequestFormNotifier extends _$HlaRequestFormNotifier { + late final HlaRepository _hlaRepository; + + @override + FutureOr build({ + required HlaRepository hlaRepository, + }) async { + _hlaRepository = hlaRepository; + return const HlaRequestFormState(); + } + + /// Resets the form, seeding the self subject with the patient's name. + void reset({required String selfName}) { + state = AsyncValue.data(HlaRequestFormState.initial(selfName: selfName)); + } + + HlaRequestFormState get _current => + state.value ?? const HlaRequestFormState(); + + void addSubject(HlaSampleSubject subject) { + final current = _current; + state = AsyncValue.data( + current.copyWith(subjects: [...current.subjects, subject]), + ); + } + + /// Removes a non-self subject at [index]. Self is protected (AC US-2.4). + void removeSubject(int index) { + final current = _current; + if (index < 0 || index >= current.subjects.length) return; + if (current.subjects[index].isSelf) return; + final updated = [...current.subjects]..removeAt(index); + state = AsyncValue.data(current.copyWith(subjects: updated)); + } + + void selectSlot(HlaAppointmentSlot slot) { + state = AsyncValue.data(_current.copyWith(selectedSlot: slot)); + } + + void setConsent(bool accepted) { + state = AsyncValue.data(_current.copyWith(consentAccepted: accepted)); + } + + /// Creates the request (status `requested`). On success the created request + /// is placed on the state so the screen can navigate to its status. + Future submit({required AppUser user}) async { + final current = _current; + log("Submitting HLA request", name: "HLA Request Form Notifier"); + state = const AsyncValue.loading(); + final Either response = + await _hlaRepository.createRequest( + user: user, + subjects: current.subjects, + consentAccepted: current.consentAccepted, + ); + response.fold( + (failure) { + log( + "Failed to submit: ${failure.message}, code: ${failure.code}", + name: "HLA Request Form Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (request) { + log("Created request ${request.uid}", + name: "HLA Request Form Notifier"); + state = AsyncValue.data(current.copyWith(submittedRequest: request)); + }, + ); + } +} diff --git a/lib/features/hla/providers/hla_request_form_notifier.g.dart b/lib/features/hla/providers/hla_request_form_notifier.g.dart new file mode 100644 index 0000000..6aa6526 --- /dev/null +++ b/lib/features/hla/providers/hla_request_form_notifier.g.dart @@ -0,0 +1,200 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hla_request_form_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hlaRequestFormNotifierHash() => + r'c4992783dcafd6d087aa22e76ccfba81dbec2824'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$HlaRequestFormNotifier + extends BuildlessAsyncNotifier { + late final HlaRepository hlaRepository; + + FutureOr build({required HlaRepository hlaRepository}); +} + +/// Holds the in-progress new request the patient is building (subjects + slot +/// + consent) and submits it. The self subject is always present and cannot be +/// removed (AC US-2.1, US-2.4). +/// +/// Copied from [HlaRequestFormNotifier]. +@ProviderFor(HlaRequestFormNotifier) +const hlaRequestFormNotifierProvider = HlaRequestFormNotifierFamily(); + +/// Holds the in-progress new request the patient is building (subjects + slot +/// + consent) and submits it. The self subject is always present and cannot be +/// removed (AC US-2.1, US-2.4). +/// +/// Copied from [HlaRequestFormNotifier]. +class HlaRequestFormNotifierFamily + extends Family> { + /// Holds the in-progress new request the patient is building (subjects + slot + /// + consent) and submits it. The self subject is always present and cannot be + /// removed (AC US-2.1, US-2.4). + /// + /// Copied from [HlaRequestFormNotifier]. + const HlaRequestFormNotifierFamily(); + + /// Holds the in-progress new request the patient is building (subjects + slot + /// + consent) and submits it. The self subject is always present and cannot be + /// removed (AC US-2.1, US-2.4). + /// + /// Copied from [HlaRequestFormNotifier]. + HlaRequestFormNotifierProvider call({required HlaRepository hlaRepository}) { + return HlaRequestFormNotifierProvider(hlaRepository: hlaRepository); + } + + @override + HlaRequestFormNotifierProvider getProviderOverride( + covariant HlaRequestFormNotifierProvider provider, + ) { + return call(hlaRepository: provider.hlaRepository); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hlaRequestFormNotifierProvider'; +} + +/// Holds the in-progress new request the patient is building (subjects + slot +/// + consent) and submits it. The self subject is always present and cannot be +/// removed (AC US-2.1, US-2.4). +/// +/// Copied from [HlaRequestFormNotifier]. +class HlaRequestFormNotifierProvider + extends + AsyncNotifierProviderImpl { + /// Holds the in-progress new request the patient is building (subjects + slot + /// + consent) and submits it. The self subject is always present and cannot be + /// removed (AC US-2.1, US-2.4). + /// + /// Copied from [HlaRequestFormNotifier]. + HlaRequestFormNotifierProvider({required HlaRepository hlaRepository}) + : this._internal( + () => HlaRequestFormNotifier()..hlaRepository = hlaRepository, + from: hlaRequestFormNotifierProvider, + name: r'hlaRequestFormNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hlaRequestFormNotifierHash, + dependencies: HlaRequestFormNotifierFamily._dependencies, + allTransitiveDependencies: + HlaRequestFormNotifierFamily._allTransitiveDependencies, + hlaRepository: hlaRepository, + ); + + HlaRequestFormNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.hlaRepository, + }) : super.internal(); + + final HlaRepository hlaRepository; + + @override + FutureOr runNotifierBuild( + covariant HlaRequestFormNotifier notifier, + ) { + return notifier.build(hlaRepository: hlaRepository); + } + + @override + Override overrideWith(HlaRequestFormNotifier Function() create) { + return ProviderOverride( + origin: this, + override: HlaRequestFormNotifierProvider._internal( + () => create()..hlaRepository = hlaRepository, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + hlaRepository: hlaRepository, + ), + ); + } + + @override + AsyncNotifierProviderElement + createElement() { + return _HlaRequestFormNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HlaRequestFormNotifierProvider && + other.hlaRepository == hlaRepository; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, hlaRepository.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin HlaRequestFormNotifierRef + on AsyncNotifierProviderRef { + /// The parameter `hlaRepository` of this provider. + HlaRepository get hlaRepository; +} + +class _HlaRequestFormNotifierProviderElement + extends + AsyncNotifierProviderElement< + HlaRequestFormNotifier, + HlaRequestFormState + > + with HlaRequestFormNotifierRef { + _HlaRequestFormNotifierProviderElement(super.provider); + + @override + HlaRepository get hlaRepository => + (origin as HlaRequestFormNotifierProvider).hlaRepository; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/hla/providers/hla_requests_notifier.dart b/lib/features/hla/providers/hla_requests_notifier.dart new file mode 100644 index 0000000..21b5dbe --- /dev/null +++ b/lib/features/hla/providers/hla_requests_notifier.dart @@ -0,0 +1,137 @@ +import 'dart:developer'; + +import 'package:fpdart/fpdart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/core.dart'; +import '../../../main.dart'; +import '../../auth/auth.dart'; +import '../models/hla_appointment_slot.dart'; +import '../models/hla_test_request.dart'; +import '../repositories/hla_repository.dart'; +import '../services/local/hla_local_service.dart'; +import '../services/remote/hla_service.dart'; + +part 'hla_requests_notifier.g.dart'; + +// ─── Shared singleton wiring (same pattern as the meds/water/health features) ─ +// +// NOTE: this mirrors the existing global-singleton DI used across the app so +// the feature stays consistent. The audit flags this pattern app-wide; it is +// intentionally left for the broader DI refactor rather than diverging here. + +final HlaService hlaService = HlaService(); +final HlaLocalService hlaLocalService = HlaLocalService(store: database.store); +final HlaRepository hlaRepository = HlaRepositoryImpl( + hlaService: hlaService, + hlaLocalService: hlaLocalService, +); + +final HlaRequestsNotifierProvider hlaRequestsNotifierProviderImpl = + hlaRequestsNotifierProvider(hlaRepository: hlaRepository); + +/// Realtime stream of a single request, keyed by request id — drives the +/// patient status timeline (AC US-4.3). Errors surface as `AsyncError`. +final hlaRequestStreamProvider = + StreamProvider.family((ref, requestId) { + return hlaRepository.streamRequest(requestId: requestId); +}); + +/// Patient-facing list of the signed-in user's HLA requests. +@Riverpod(keepAlive: true) +class HlaRequestsNotifier extends _$HlaRequestsNotifier { + late final HlaRepository _hlaRepository; + + @override + FutureOr> build({ + required HlaRepository hlaRepository, + }) async { + _hlaRepository = hlaRepository; + return []; + } + + Future getRequests({required AppUser user}) async { + log("Getting HLA requests", name: "HLA Requests Notifier"); + state = const AsyncValue.loading(); + final Either> response = + await _hlaRepository.getRequestsForUser(userId: user.uid); + response.fold( + (failure) { + log( + "Failed to get requests: ${failure.message}, code: ${failure.code}", + name: "HLA Requests Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (requests) { + log( + "Loaded ${requests.length} request(s)", + name: "HLA Requests Notifier", + ); + state = AsyncValue.data(requests); + }, + ); + } + + /// Books [slot] for [request], confirming the appointment (status + /// `bookingConfirmed`). Surfaces "slot no longer available" if it filled + /// (AC US-3.3). + Future bookAppointment({ + required HlaTestRequest request, + required HlaAppointmentSlot slot, + required AppUser user, + }) async { + log("Booking slot for ${request.uid}", name: "HLA Requests Notifier"); + state = const AsyncValue.loading(); + final Either response = + await _hlaRepository.bookAppointment(request: request, slot: slot); + await response.fold( + (failure) async { + log( + "Failed to book: ${failure.message}, code: ${failure.code}", + name: "HLA Requests Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (_) async { + log("Booked slot for ${request.uid}", name: "HLA Requests Notifier"); + await getRequests(user: user); + }, + ); + } + + Future cancelRequest({ + required HlaTestRequest request, + required AppUser user, + }) async { + log("Cancelling request ${request.uid}", name: "HLA Requests Notifier"); + state = const AsyncValue.loading(); + final Either response = + await _hlaRepository.cancelRequest(request: request); + await response.fold( + (failure) async { + log( + "Failed to cancel: ${failure.message}, code: ${failure.code}", + name: "HLA Requests Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (_) async { + log("Cancelled ${request.uid}", name: "HLA Requests Notifier"); + await getRequests(user: user); + }, + ); + } +} diff --git a/lib/features/hla/providers/hla_requests_notifier.g.dart b/lib/features/hla/providers/hla_requests_notifier.g.dart new file mode 100644 index 0000000..aa5bf8f --- /dev/null +++ b/lib/features/hla/providers/hla_requests_notifier.g.dart @@ -0,0 +1,184 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hla_requests_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hlaRequestsNotifierHash() => + r'b4b70f4a2f939e2eb41e8196e00ba98dd9dd699e'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$HlaRequestsNotifier + extends BuildlessAsyncNotifier> { + late final HlaRepository hlaRepository; + + FutureOr> build({required HlaRepository hlaRepository}); +} + +/// Patient-facing list of the signed-in user's HLA requests. +/// +/// Copied from [HlaRequestsNotifier]. +@ProviderFor(HlaRequestsNotifier) +const hlaRequestsNotifierProvider = HlaRequestsNotifierFamily(); + +/// Patient-facing list of the signed-in user's HLA requests. +/// +/// Copied from [HlaRequestsNotifier]. +class HlaRequestsNotifierFamily + extends Family>> { + /// Patient-facing list of the signed-in user's HLA requests. + /// + /// Copied from [HlaRequestsNotifier]. + const HlaRequestsNotifierFamily(); + + /// Patient-facing list of the signed-in user's HLA requests. + /// + /// Copied from [HlaRequestsNotifier]. + HlaRequestsNotifierProvider call({required HlaRepository hlaRepository}) { + return HlaRequestsNotifierProvider(hlaRepository: hlaRepository); + } + + @override + HlaRequestsNotifierProvider getProviderOverride( + covariant HlaRequestsNotifierProvider provider, + ) { + return call(hlaRepository: provider.hlaRepository); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hlaRequestsNotifierProvider'; +} + +/// Patient-facing list of the signed-in user's HLA requests. +/// +/// Copied from [HlaRequestsNotifier]. +class HlaRequestsNotifierProvider + extends + AsyncNotifierProviderImpl> { + /// Patient-facing list of the signed-in user's HLA requests. + /// + /// Copied from [HlaRequestsNotifier]. + HlaRequestsNotifierProvider({required HlaRepository hlaRepository}) + : this._internal( + () => HlaRequestsNotifier()..hlaRepository = hlaRepository, + from: hlaRequestsNotifierProvider, + name: r'hlaRequestsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hlaRequestsNotifierHash, + dependencies: HlaRequestsNotifierFamily._dependencies, + allTransitiveDependencies: + HlaRequestsNotifierFamily._allTransitiveDependencies, + hlaRepository: hlaRepository, + ); + + HlaRequestsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.hlaRepository, + }) : super.internal(); + + final HlaRepository hlaRepository; + + @override + FutureOr> runNotifierBuild( + covariant HlaRequestsNotifier notifier, + ) { + return notifier.build(hlaRepository: hlaRepository); + } + + @override + Override overrideWith(HlaRequestsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: HlaRequestsNotifierProvider._internal( + () => create()..hlaRepository = hlaRepository, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + hlaRepository: hlaRepository, + ), + ); + } + + @override + AsyncNotifierProviderElement> + createElement() { + return _HlaRequestsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HlaRequestsNotifierProvider && + other.hlaRepository == hlaRepository; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, hlaRepository.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin HlaRequestsNotifierRef on AsyncNotifierProviderRef> { + /// The parameter `hlaRepository` of this provider. + HlaRepository get hlaRepository; +} + +class _HlaRequestsNotifierProviderElement + extends + AsyncNotifierProviderElement> + with HlaRequestsNotifierRef { + _HlaRequestsNotifierProviderElement(super.provider); + + @override + HlaRepository get hlaRepository => + (origin as HlaRequestsNotifierProvider).hlaRepository; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/hla/providers/hla_slots_notifier.dart b/lib/features/hla/providers/hla_slots_notifier.dart new file mode 100644 index 0000000..60e7a56 --- /dev/null +++ b/lib/features/hla/providers/hla_slots_notifier.dart @@ -0,0 +1,51 @@ +import 'dart:developer'; + +import 'package:fpdart/fpdart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/core.dart'; +import '../hla.dart'; + +part 'hla_slots_notifier.g.dart'; + +final HlaSlotsNotifierProvider hlaSlotsNotifierProviderImpl = + hlaSlotsNotifierProvider(hlaRepository: hlaRepository); + +/// Loads the bookable appointment slots a patient can choose from (future, +/// active, not full). +@Riverpod(keepAlive: true) +class HlaSlotsNotifier extends _$HlaSlotsNotifier { + late final HlaRepository _hlaRepository; + + @override + FutureOr> build({ + required HlaRepository hlaRepository, + }) async { + _hlaRepository = hlaRepository; + return []; + } + + Future getAvailableSlots() async { + log("Getting available slots", name: "HLA Slots Notifier"); + state = const AsyncValue.loading(); + final Either> response = + await _hlaRepository.getAvailableSlots(); + response.fold( + (failure) { + log( + "Failed to get slots: ${failure.message}, code: ${failure.code}", + name: "HLA Slots Notifier", + stackTrace: failure.stackTrace, + ); + state = AsyncValue.error( + failure, + failure.stackTrace ?? StackTrace.current, + ); + }, + (slots) { + log("Loaded ${slots.length} slot(s)", name: "HLA Slots Notifier"); + state = AsyncValue.data(slots); + }, + ); + } +} diff --git a/lib/features/hla/providers/hla_slots_notifier.g.dart b/lib/features/hla/providers/hla_slots_notifier.g.dart new file mode 100644 index 0000000..6803efa --- /dev/null +++ b/lib/features/hla/providers/hla_slots_notifier.g.dart @@ -0,0 +1,192 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hla_slots_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$hlaSlotsNotifierHash() => r'1b1c107238507d6127efdfdd9331da985178dc88'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$HlaSlotsNotifier + extends BuildlessAsyncNotifier> { + late final HlaRepository hlaRepository; + + FutureOr> build({ + required HlaRepository hlaRepository, + }); +} + +/// Loads the bookable appointment slots a patient can choose from (future, +/// active, not full). +/// +/// Copied from [HlaSlotsNotifier]. +@ProviderFor(HlaSlotsNotifier) +const hlaSlotsNotifierProvider = HlaSlotsNotifierFamily(); + +/// Loads the bookable appointment slots a patient can choose from (future, +/// active, not full). +/// +/// Copied from [HlaSlotsNotifier]. +class HlaSlotsNotifierFamily + extends Family>> { + /// Loads the bookable appointment slots a patient can choose from (future, + /// active, not full). + /// + /// Copied from [HlaSlotsNotifier]. + const HlaSlotsNotifierFamily(); + + /// Loads the bookable appointment slots a patient can choose from (future, + /// active, not full). + /// + /// Copied from [HlaSlotsNotifier]. + HlaSlotsNotifierProvider call({required HlaRepository hlaRepository}) { + return HlaSlotsNotifierProvider(hlaRepository: hlaRepository); + } + + @override + HlaSlotsNotifierProvider getProviderOverride( + covariant HlaSlotsNotifierProvider provider, + ) { + return call(hlaRepository: provider.hlaRepository); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'hlaSlotsNotifierProvider'; +} + +/// Loads the bookable appointment slots a patient can choose from (future, +/// active, not full). +/// +/// Copied from [HlaSlotsNotifier]. +class HlaSlotsNotifierProvider + extends + AsyncNotifierProviderImpl> { + /// Loads the bookable appointment slots a patient can choose from (future, + /// active, not full). + /// + /// Copied from [HlaSlotsNotifier]. + HlaSlotsNotifierProvider({required HlaRepository hlaRepository}) + : this._internal( + () => HlaSlotsNotifier()..hlaRepository = hlaRepository, + from: hlaSlotsNotifierProvider, + name: r'hlaSlotsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$hlaSlotsNotifierHash, + dependencies: HlaSlotsNotifierFamily._dependencies, + allTransitiveDependencies: + HlaSlotsNotifierFamily._allTransitiveDependencies, + hlaRepository: hlaRepository, + ); + + HlaSlotsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.hlaRepository, + }) : super.internal(); + + final HlaRepository hlaRepository; + + @override + FutureOr> runNotifierBuild( + covariant HlaSlotsNotifier notifier, + ) { + return notifier.build(hlaRepository: hlaRepository); + } + + @override + Override overrideWith(HlaSlotsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: HlaSlotsNotifierProvider._internal( + () => create()..hlaRepository = hlaRepository, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + hlaRepository: hlaRepository, + ), + ); + } + + @override + AsyncNotifierProviderElement> + createElement() { + return _HlaSlotsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is HlaSlotsNotifierProvider && + other.hlaRepository == hlaRepository; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, hlaRepository.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin HlaSlotsNotifierRef + on AsyncNotifierProviderRef> { + /// The parameter `hlaRepository` of this provider. + HlaRepository get hlaRepository; +} + +class _HlaSlotsNotifierProviderElement + extends + AsyncNotifierProviderElement> + with HlaSlotsNotifierRef { + _HlaSlotsNotifierProviderElement(super.provider); + + @override + HlaRepository get hlaRepository => + (origin as HlaSlotsNotifierProvider).hlaRepository; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package From 2addd7c59515814b049a91a6446a8998421fcce8 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:51 +0100 Subject: [PATCH 08/33] add hla reusable components - status chip and vertical status timeline - subject tile + add-subject bottom sheet - slot tile for the appointment picker --- .../hla/screens/components/components.dart | 4 + .../hla/screens/components/hla_slot_tile.dart | 77 +++++++ .../screens/components/hla_status_chip.dart | 58 ++++++ .../components/hla_status_timeline.dart | 172 +++++++++++++++ .../screens/components/hla_subject_tile.dart | 195 ++++++++++++++++++ 5 files changed, 506 insertions(+) create mode 100644 lib/features/hla/screens/components/components.dart create mode 100644 lib/features/hla/screens/components/hla_slot_tile.dart create mode 100644 lib/features/hla/screens/components/hla_status_chip.dart create mode 100644 lib/features/hla/screens/components/hla_status_timeline.dart create mode 100644 lib/features/hla/screens/components/hla_subject_tile.dart diff --git a/lib/features/hla/screens/components/components.dart b/lib/features/hla/screens/components/components.dart new file mode 100644 index 0000000..816a092 --- /dev/null +++ b/lib/features/hla/screens/components/components.dart @@ -0,0 +1,4 @@ +export 'hla_slot_tile.dart'; +export 'hla_status_chip.dart'; +export 'hla_status_timeline.dart'; +export 'hla_subject_tile.dart'; diff --git a/lib/features/hla/screens/components/hla_slot_tile.dart b/lib/features/hla/screens/components/hla_slot_tile.dart new file mode 100644 index 0000000..ad24714 --- /dev/null +++ b/lib/features/hla/screens/components/hla_slot_tile.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../../core/core.dart'; +import '../../../../core/extensions/date_time_formatter.dart'; +import '../../models/hla_appointment_slot.dart'; + +/// A selectable appointment slot row showing date/time, location, and +/// remaining seats. +class HlaSlotTile extends StatelessWidget { + const HlaSlotTile({ + super.key, + required this.slot, + this.isSelected = false, + this.onTap, + }); + + final HlaAppointmentSlot slot; + final bool isSelected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(kPadding16), + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + border: isSelected + ? Border.all(color: theme.colorScheme.primary, width: 1.5) + : null, + ), + child: Row( + children: [ + Icon( + FluentIcons.calendar_clock_24_regular, + color: theme.colorScheme.primary, + ), + const Gap(kPadding12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slot.dateTime.toLocal().formatDateWithTime(context), + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + const Gap(kPadding4), + Text( + slot.location, + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + Text( + "${slot.remainingSeats} left", + style: AppTextStyles.bodySmall.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/hla/screens/components/hla_status_chip.dart b/lib/features/hla/screens/components/hla_status_chip.dart new file mode 100644 index 0000000..9dcd78d --- /dev/null +++ b/lib/features/hla/screens/components/hla_status_chip.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/core.dart'; + +/// A small colored chip representing an [HlaTestStatus]. Cancelled is red, +/// results stages are green, everything else uses the brand purple. +class HlaStatusChip extends StatelessWidget { + const HlaStatusChip({super.key, required this.status}); + + final HlaTestStatus status; + + @override + Widget build(BuildContext context) { + final bool isDark = Theme.of(context).brightness == Brightness.dark; + final (Color bg, Color fg) = _colors(isDark); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: kPadding12, + vertical: kPadding4, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(kPadding24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(status.icon, size: 14, color: fg), + const SizedBox(width: kPadding4), + Text( + status.label, + style: AppTextStyles.bodySmall.copyWith( + color: fg, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + (Color, Color) _colors(bool isDark) { + if (status.isCancelledState) { + return isDark + ? (AppColours.red20, AppColours.red90) + : (AppColours.red95, AppColours.red40); + } + if (status.isResultsReady) { + return isDark + ? (AppColours.green20, AppColours.green90) + : (AppColours.green95, AppColours.green40); + } + return isDark + ? (AppColours.purple20, AppColours.purple90) + : (AppColours.purple95, AppColours.purple40); + } +} diff --git a/lib/features/hla/screens/components/hla_status_timeline.dart b/lib/features/hla/screens/components/hla_status_timeline.dart new file mode 100644 index 0000000..95001bd --- /dev/null +++ b/lib/features/hla/screens/components/hla_status_timeline.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/core.dart'; +import '../../../../core/extensions/date_time_formatter.dart'; +import '../../models/hla_status_event.dart'; + +/// A vertical timeline of the HLA journey. Completed stages are filled and +/// show the timestamp + any staff note; the current stage is highlighted; +/// upcoming stages are dimmed (AC US-4.1, US-4.2). +class HlaStatusTimeline extends StatelessWidget { + const HlaStatusTimeline({ + super.key, + required this.currentStatus, + required this.history, + }); + + final HlaTestStatus currentStatus; + final List history; + + /// The forward sequence, excluding [HlaTestStatus.cancelled]. + static final List _stages = HlaTestStatus.values + .where((s) => s != HlaTestStatus.cancelled) + .toList(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + // A cancelled request shows its history as-is rather than the full ladder. + if (currentStatus.isCancelledState) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < history.length; i++) + _TimelineRow( + event: history[i], + label: history[i].status.label, + icon: history[i].status.icon, + isCompleted: true, + isCurrent: i == history.length - 1, + isLast: i == history.length - 1, + isCancelled: history[i].status.isCancelledState, + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < _stages.length; i++) + _TimelineRow( + event: _eventFor(_stages[i]), + label: _stages[i].label, + icon: _stages[i].icon, + isCompleted: _stages[i].order <= currentStatus.order, + isCurrent: _stages[i] == currentStatus, + isLast: i == _stages.length - 1, + isCancelled: false, + theme: theme, + ), + ], + ); + } + + HlaStatusEvent? _eventFor(HlaTestStatus status) { + for (final event in history) { + if (event.status == status) return event; + } + return null; + } +} + +class _TimelineRow extends StatelessWidget { + const _TimelineRow({ + required this.label, + required this.icon, + required this.isCompleted, + required this.isCurrent, + required this.isLast, + required this.isCancelled, + this.event, + this.theme, + }); + + final String label; + final IconData icon; + final bool isCompleted; + final bool isCurrent; + final bool isLast; + final bool isCancelled; + final HlaStatusEvent? event; + final ThemeData? theme; + + @override + Widget build(BuildContext context) { + final ThemeData theme = this.theme ?? Theme.of(context); + final Color activeColor = + isCancelled ? theme.colorScheme.error : theme.colorScheme.primary; + const Color dimColor = AppColours.neutral60; + final Color nodeColor = isCompleted ? activeColor : Colors.transparent; + final Color lineColor = isCompleted ? activeColor : AppColours.neutral80; + final Color labelColor = + isCompleted ? theme.colorScheme.onSurface : dimColor; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: nodeColor, + shape: BoxShape.circle, + border: Border.all(color: lineColor, width: 2), + ), + child: Icon( + icon, + size: 16, + color: isCompleted ? theme.colorScheme.onPrimary : dimColor, + ), + ), + if (!isLast) + Expanded( + child: Container(width: 2, color: lineColor), + ), + ], + ), + const SizedBox(width: kPadding12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: kPadding24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTextStyles.bodyLarge.copyWith( + color: labelColor, + fontWeight: + isCurrent ? FontWeight.w700 : FontWeight.w500, + ), + ), + if (event != null) ...[ + const SizedBox(height: kPadding4), + Text( + event!.timestamp.toLocal().formatDateWithTime(context), + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + if (event!.note != null && event!.note!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: kPadding4), + child: Text( + event!.note!, + style: AppTextStyles.bodySmall + .copyWith(color: labelColor), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/hla/screens/components/hla_subject_tile.dart b/lib/features/hla/screens/components/hla_subject_tile.dart new file mode 100644 index 0000000..69ab484 --- /dev/null +++ b/lib/features/hla/screens/components/hla_subject_tile.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../../components/components.dart'; +import '../../../../core/core.dart'; +import '../../models/hla_sample_subject.dart'; + +/// A row representing one sample subject in the panel. Shows the name and +/// relation; the self subject is badged and never deletable (AC US-2.4). +class HlaSubjectTile extends StatelessWidget { + const HlaSubjectTile({ + super.key, + required this.subject, + this.onDelete, + }); + + final HlaSampleSubject subject; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: kPadding16, + vertical: kPadding12, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + FluentIcons.person_24_regular, + size: 18, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const Gap(kPadding12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subject.name, + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + subject.isSelf ? "You" : subject.relation.label, + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + if (onDelete != null && !subject.isSelf) + IconButton( + onPressed: onDelete, + icon: Icon( + FluentIcons.delete_24_regular, + color: theme.colorScheme.error, + ), + ), + ], + ), + ); + } +} + +/// Opens a bottom sheet to add a new subject. Returns the created +/// [HlaSampleSubject], or null if cancelled. +Future showAddSubjectSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const _AddSubjectSheet(), + ); +} + +class _AddSubjectSheet extends StatefulWidget { + const _AddSubjectSheet(); + + @override + State<_AddSubjectSheet> createState() => _AddSubjectSheetState(); +} + +class _AddSubjectSheetState extends State<_AddSubjectSheet> { + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + HlaSubjectRelation _relation = HlaSubjectRelation.sibling; + Genotype? _genotype; + String? _nameError; + + @override + void dispose() { + _nameController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + void _submit() { + final String name = _nameController.text.trim(); + if (name.isEmpty) { + setState(() => _nameError = "Name is required"); + return; + } + Navigator.pop( + context, + HlaSampleSubject( + name: name, + relation: _relation, + genotype: _genotype, + phone: + _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + // Relations a patient can add (everything except `self`). + final List relations = HlaSubjectRelation.values + .where((r) => r != HlaSubjectRelation.self) + .toList(); + + return AppBottomSheet( + title: "Add subject", + buttonLabel: "Add to panel", + onPressed: _submit, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.words, + decoration: AppInputDecoration.inputDecoration(context).copyWith( + hintText: "Name", + errorText: _nameError, + ), + ), + const Gap(kPadding16), + DropdownButtonFormField( + initialValue: _relation, + isExpanded: true, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Relation"), + items: [ + for (final r in relations) + DropdownMenuItem(value: r, child: Text(r.label)), + ], + onChanged: (value) { + if (value != null) setState(() => _relation = value); + }, + ), + const Gap(kPadding16), + DropdownButtonFormField( + initialValue: _genotype, + isExpanded: true, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Genotype (optional)"), + items: [ + for (final g in Genotype.values) + DropdownMenuItem( + value: g, + child: Text(g.name.toUpperCase()), + ), + ], + onChanged: (value) => setState(() => _genotype = value), + ), + const Gap(kPadding16), + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Phone (optional)"), + ), + const Gap(kPadding8), + Text( + "The patient (you) is always included in the panel.", + style: AppTextStyles.bodySmall + .copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} From a44628aee1953f5e6a21e0058422374abe1aaa4c Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:51 +0100 Subject: [PATCH 09/33] add hla patient screens - intro explainer with request / view-active cta - request form: build panel + consent + submit - requests list, realtime status timeline (book/cancel/results actions), results view/download - consent checkbox added for sending medical data to partner labs abroad --- .../hla/screens/hla_intro_screen.dart | 229 ++++++++++++ .../hla/screens/hla_request_form_screen.dart | 186 ++++++++++ .../hla/screens/hla_requests_list_screen.dart | 194 +++++++++++ .../hla/screens/hla_results_screen.dart | 185 ++++++++++ .../hla/screens/hla_status_screen.dart | 329 ++++++++++++++++++ 5 files changed, 1123 insertions(+) create mode 100644 lib/features/hla/screens/hla_intro_screen.dart create mode 100644 lib/features/hla/screens/hla_request_form_screen.dart create mode 100644 lib/features/hla/screens/hla_requests_list_screen.dart create mode 100644 lib/features/hla/screens/hla_results_screen.dart create mode 100644 lib/features/hla/screens/hla_status_screen.dart diff --git a/lib/features/hla/screens/hla_intro_screen.dart b/lib/features/hla/screens/hla_intro_screen.dart new file mode 100644 index 0000000..042bdae --- /dev/null +++ b/lib/features/hla/screens/hla_intro_screen.dart @@ -0,0 +1,229 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../components/components.dart'; +import '../../../core/core.dart'; +import '../../auth/auth.dart'; +import '../hla.dart'; + +/// Entry point for the HLA typing feature: a plain-language explainer of what +/// HLA typing is and why it matters, plus the primary CTA. If the patient +/// already has an active request, the CTA becomes "View your active request" +/// (AC US-1.1, US-1.3). +@RoutePage(name: HlaIntroScreen.name) +class HlaIntroScreen extends ConsumerStatefulWidget { + static const String path = "/hla"; + static const String name = "HlaIntroScreen"; + const HlaIntroScreen({super.key}); + + @override + ConsumerState createState() => _HlaIntroScreenState(); +} + +class _HlaIntroScreenState extends ConsumerState { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final AppUser user = ref.read(userNotifierProviderImpl).value ?? AppUser.empty; + if (user.isNotEmpty) { + await ref + .read(hlaRequestsNotifierProviderImpl.notifier) + .getRequests(user: user); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppUser user = ref.watch(userNotifierProviderImpl).value ?? AppUser.empty; + final List requests = + ref.watch(hlaRequestsNotifierProviderImpl).value ?? []; + + HlaTestRequest? active; + for (final r in requests) { + if (!r.isCancelled && !r.status.isTerminal) { + active = r; + break; + } + } + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "HLA Typing"), + Container( + width: double.infinity, + padding: const EdgeInsets.all(kPadding24), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(kPadding24), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + FluentIcons.organization_24_regular, + size: 40, + color: theme.colorScheme.onPrimaryContainer, + ), + const Gap(kPadding16), + Text( + "Find your transplant match", + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const Gap(kPadding8), + Text( + "HLA typing is a blood test that identifies the proteins " + "(markers) your immune system uses to tell your cells " + "apart from others. Matching these markers with a donor " + "is the first step toward a bone marrow or stem cell " + "transplant.", + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + height: 1.5, + ), + ), + ], + ), + ), + const Gap(kPadding24), + Text("How it works", style: theme.textTheme.titleMedium), + const Gap(kPadding12), + const _Step( + number: 1, + title: "Request the test", + body: + "Tell us you'd like an HLA typing test and list the people " + "whose samples will be collected with yours.", + ), + const _Step( + number: 2, + title: "Book a collection appointment", + body: + "Pick a time to come in and hand over your sample at one " + "of our collection points.", + ), + const _Step( + number: 3, + title: "Track your sample's journey", + body: + "Your sample is cross-matched against your panel and sent " + "to our partner lab. Follow every step in the app.", + ), + const _Step( + number: 4, + title: "Receive your results", + body: "Your results are delivered securely inside the app.", + isLast: true, + ), + const Gap(kPadding32), + if (active != null) + Builder( + builder: (context) { + final HlaTestRequest activeRequest = active!; + return AppButton( + label: "View your active request", + trailingIcon: FluentIcons.arrow_right_24_regular, + onPressed: () { + context.router.pushNamed( + "${HlaStatusScreen.path}/${activeRequest.uid}", + ); + }, + ); + }, + ) + else + AppButton( + label: "Request test", + trailingIcon: FluentIcons.arrow_right_24_regular, + onPressed: user.isEmpty + ? () {} + : () { + context.router.pushNamed(HlaRequestFormScreen.path); + }, + ), + const Gap(kPadding12), + if (requests.isNotEmpty) + AppButton( + label: "My requests", + buttonType: ButtonType.outline, + onPressed: () { + context.router.pushNamed(HlaRequestsListScreen.path); + }, + ), + const Gap(kPadding64), + ], + ), + ), + ), + ); + } +} + +class _Step extends StatelessWidget { + const _Step({ + required this.number, + required this.title, + required this.body, + this.isLast = false, + }); + + final int number; + final String title; + final String body; + final bool isLast; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : kPadding16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.secondaryContainer, + child: Text( + "$number", + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const Gap(kPadding12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + const Gap(kPadding4), + Text( + body, + style: AppTextStyles.bodyMedium + .copyWith(color: AppColours.neutral50, height: 1.4), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/hla/screens/hla_request_form_screen.dart b/lib/features/hla/screens/hla_request_form_screen.dart new file mode 100644 index 0000000..83a8934 --- /dev/null +++ b/lib/features/hla/screens/hla_request_form_screen.dart @@ -0,0 +1,186 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../components/components.dart'; +import '../../../core/core.dart'; +import '../../auth/auth.dart'; +import '../hla.dart'; + +/// Lets the patient build the sample panel, accept the data-transfer consent, +/// and submit a new request (US-1, US-2). The collection appointment is booked +/// afterwards from the status screen. +@RoutePage(name: HlaRequestFormScreen.name) +class HlaRequestFormScreen extends ConsumerStatefulWidget { + static const String path = "/hla_request"; + static const String name = "HlaRequestFormScreen"; + const HlaRequestFormScreen({super.key}); + + @override + ConsumerState createState() => + _HlaRequestFormScreenState(); +} + +class _HlaRequestFormScreenState extends ConsumerState { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final AppUser user = + ref.read(userNotifierProviderImpl).value ?? AppUser.empty; + ref + .read(hlaRequestFormNotifierProviderImpl.notifier) + .reset(selfName: user.getDisplayName()); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppUser user = + ref.watch(userNotifierProviderImpl).value ?? AppUser.empty; + + // React to submission: navigate on success, surface errors via snackbar. + ref.listen(hlaRequestFormNotifierProviderImpl, (previous, next) { + next.whenOrNull( + error: (error, _) { + showCustomSnackBar(context: context, error: error); + }, + data: (state) { + final request = state.submittedRequest; + if (request != null) { + context.router + .replaceNamed("${HlaStatusScreen.path}/${request.uid}"); + } + }, + ); + }); + + final formAsync = ref.watch(hlaRequestFormNotifierProviderImpl); + final HlaRequestFormState formState = + formAsync.value ?? const HlaRequestFormState(); + final bool isSubmitting = formAsync.isLoading; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "Request HLA Test"), + Text("Sample panel", style: theme.textTheme.titleMedium), + const Gap(kPadding4), + Text( + "List everyone whose sample will be collected and " + "cross-matched with yours — relatives and potential donors.", + style: AppTextStyles.bodyMedium + .copyWith(color: AppColours.neutral50), + ), + const Gap(kPadding16), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: formState.subjects.length, + separatorBuilder: (_, __) => const Gap(kPadding8), + itemBuilder: (context, index) { + return HlaSubjectTile( + subject: formState.subjects[index], + onDelete: () => ref + .read(hlaRequestFormNotifierProviderImpl.notifier) + .removeSubject(index), + ); + }, + ), + const Gap(kPadding12), + AppButton( + label: "Add subject", + icon: FluentIcons.add_24_regular, + buttonType: ButtonType.outline, + onPressed: () async { + final subject = await showAddSubjectSheet(context); + if (subject != null) { + ref + .read(hlaRequestFormNotifierProviderImpl.notifier) + .addSubject(subject); + } + }, + ), + const Gap(kPadding32), + _ConsentTile( + value: formState.consentAccepted, + onChanged: (value) => ref + .read(hlaRequestFormNotifierProviderImpl.notifier) + .setConsent(value), + ), + const Gap(kPadding32), + AppButton( + label: "Submit request", + isLoading: isSubmitting, + onPressed: (!formState.consentAccepted || user.isEmpty) + ? () { + showCustomSnackBar( + context: context, + message: + "Please accept the consent to continue", + mode: SnackBarMode.notification, + ); + } + : () { + ref + .read(hlaRequestFormNotifierProviderImpl.notifier) + .submit(user: user); + }, + ), + const Gap(kPadding64), + ], + ), + ), + ), + ); + } +} + +class _ConsentTile extends StatelessWidget { + const _ConsentTile({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(kPadding16), + onTap: () => onChanged(!value), + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: value, + onChanged: (v) => onChanged(v ?? false), + ), + const Gap(kPadding8), + Expanded( + child: Text( + "I understand my sample and panel details will be sent to a " + "partner lab (including labs abroad) for HLA typing, and I " + "consent to this processing of my medical data.", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.onSurface, height: 1.4), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/hla/screens/hla_requests_list_screen.dart b/lib/features/hla/screens/hla_requests_list_screen.dart new file mode 100644 index 0000000..5f48082 --- /dev/null +++ b/lib/features/hla/screens/hla_requests_list_screen.dart @@ -0,0 +1,194 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../components/components.dart'; +import '../../../core/core.dart'; +import '../../../core/extensions/date_time_formatter.dart'; +import '../../auth/auth.dart'; +import '../hla.dart'; + +/// The patient's current and past HLA requests, active first (US-6). +@RoutePage(name: HlaRequestsListScreen.name) +class HlaRequestsListScreen extends ConsumerStatefulWidget { + static const String path = "/hla_requests"; + static const String name = "HlaRequestsListScreen"; + const HlaRequestsListScreen({super.key}); + + @override + ConsumerState createState() => + _HlaRequestsListScreenState(); +} + +class _HlaRequestsListScreenState extends ConsumerState { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final AppUser user = + ref.read(userNotifierProviderImpl).value ?? AppUser.empty; + if (user.isNotEmpty) { + ref + .read(hlaRequestsNotifierProviderImpl.notifier) + .getRequests(user: user); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final requestsAsync = ref.watch(hlaRequestsNotifierProviderImpl); + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "My Requests"), + requestsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.only(top: kPadding64), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => _ErrorState( + message: (error is Failure) + ? error.message + : "Couldn't load your requests", + ), + data: (requests) { + if (requests.isEmpty) { + return const _EmptyState(); + } + final sorted = [...requests]..sort((a, b) { + final bool aActive = + !a.isCancelled && !a.status.isTerminal; + final bool bActive = + !b.isCancelled && !b.status.isTerminal; + if (aActive != bActive) return aActive ? -1 : 1; + return (b.createdAt ?? DateTime(0)) + .compareTo(a.createdAt ?? DateTime(0)); + }); + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + separatorBuilder: (_, __) => const Gap(kPadding12), + itemBuilder: (context, index) => + _RequestCard(request: sorted[index]), + ); + }, + ), + const Gap(kPadding64), + ], + ), + ), + ), + ); + } +} + +class _RequestCard extends StatelessWidget { + const _RequestCard({required this.request}); + + final HlaTestRequest request; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(kPadding16), + onTap: () => context.router + .pushNamed("${HlaStatusScreen.path}/${request.uid}"), + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + HlaStatusChip(status: request.status), + const Spacer(), + const Icon( + FluentIcons.chevron_right_24_regular, + size: 18, + color: AppColours.neutral50, + ), + ], + ), + const Gap(kPadding12), + Text( + request.appointmentDate != null + ? "Appointment: ${request.appointmentDate!.toLocal().formatDateWithTime(context)}" + : "No appointment booked yet", + style: AppTextStyles.bodyMedium + .copyWith(color: AppColours.neutral50), + ), + const Gap(kPadding4), + Text( + "${request.subjects.length} subject(s) in panel", + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: kPadding64), + child: Center( + child: Column( + children: [ + const Icon( + FluentIcons.document_24_regular, + size: 48, + color: AppColours.neutral60, + ), + const Gap(kPadding16), + Text( + "You have no HLA requests yet", + style: theme.textTheme.bodyLarge + ?.copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: kPadding64), + child: Center( + child: Text( + message, + textAlign: TextAlign.center, + style: AppTextStyles.bodyMedium + .copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + ); + } +} diff --git a/lib/features/hla/screens/hla_results_screen.dart b/lib/features/hla/screens/hla_results_screen.dart new file mode 100644 index 0000000..db8342b --- /dev/null +++ b/lib/features/hla/screens/hla_results_screen.dart @@ -0,0 +1,185 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../components/components.dart'; +import '../../../core/core.dart'; +import '../hla.dart'; + +/// Shows the uploaded result document and the optional staff summary (US-5). +/// Falls back to a clear empty state when results are not yet uploaded. +@RoutePage(name: HlaResultsScreen.name) +class HlaResultsScreen extends ConsumerWidget { + static const String path = "/hla_results"; + static const String name = "HlaResultsScreen"; + + const HlaResultsScreen({ + super.key, + @PathParam('requestId') required this.requestId, + }); + + final String requestId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final requestAsync = ref.watch(hlaRequestStreamProvider(requestId)); + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "Results"), + requestAsync.when( + loading: () => const Padding( + padding: EdgeInsets.only(top: kPadding64), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Padding( + padding: const EdgeInsets.only(top: kPadding64), + child: Text( + (error is Failure) ? error.message : "Couldn't load results", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.error), + ), + ), + data: (request) { + if (request == null || !request.hasResults) { + return const _NotReadyState(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (request.resultSummary != null && + request.resultSummary!.isNotEmpty) ...[ + Text("Summary", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Text( + request.resultSummary!, + style: theme.textTheme.bodyMedium + ?.copyWith(height: 1.5), + ), + ), + const Gap(kPadding24), + ], + Text("Document", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + _ResultFileCard( + fileName: request.resultFileName ?? "Result document", + onOpen: () => _openFile(context, request.resultFileUrl!), + ), + const Gap(kPadding64), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } + + Future _openFile(BuildContext context, String url) async { + final Uri uri = Uri.parse(url); + final bool ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + showCustomSnackBar( + context: context, + message: "Couldn't open the document", + mode: SnackBarMode.error, + ); + } + } +} + +class _ResultFileCard extends StatelessWidget { + const _ResultFileCard({required this.fileName, required this.onOpen}); + + final String fileName; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(kPadding16), + onTap: onOpen, + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + children: [ + Icon( + FluentIcons.document_pdf_24_regular, + color: theme.colorScheme.primary, + ), + const Gap(kPadding12), + Expanded( + child: Text( + fileName, + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Icon( + FluentIcons.open_24_regular, + color: theme.colorScheme.primary, + ), + ], + ), + ), + ); + } +} + +class _NotReadyState extends StatelessWidget { + const _NotReadyState(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: kPadding64), + child: Center( + child: Column( + children: [ + const Icon( + FluentIcons.hourglass_24_regular, + size: 48, + color: AppColours.neutral60, + ), + const Gap(kPadding16), + Text( + "Your results aren't uploaded yet", + style: theme.textTheme.bodyLarge + ?.copyWith(color: AppColours.neutral50), + ), + const Gap(kPadding4), + Text( + "We'll let you know as soon as they're ready.", + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/hla/screens/hla_status_screen.dart b/lib/features/hla/screens/hla_status_screen.dart new file mode 100644 index 0000000..12d16b7 --- /dev/null +++ b/lib/features/hla/screens/hla_status_screen.dart @@ -0,0 +1,329 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../components/components.dart'; +import '../../../core/core.dart'; +import '../../../core/extensions/date_time_formatter.dart'; +import '../../auth/auth.dart'; +import '../hla.dart'; + +/// The journey timeline for a single request, updated in near real time via a +/// Firestore snapshot stream (US-4). Hosts the book-appointment, cancel, and +/// view-results actions. +@RoutePage(name: HlaStatusScreen.name) +class HlaStatusScreen extends ConsumerWidget { + static const String path = "/hla_status"; + static const String name = "HlaStatusScreen"; + + const HlaStatusScreen({super.key, @PathParam('requestId') required this.requestId}); + + final String requestId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final AppUser user = + ref.watch(userNotifierProviderImpl).value ?? AppUser.empty; + final requestAsync = ref.watch(hlaRequestStreamProvider(requestId)); + + // Surface booking/cancel failures from the actions notifier. + ref.listen(hlaRequestsNotifierProviderImpl, (previous, next) { + next.whenOrNull( + error: (error, _) => + showCustomSnackBar(context: context, error: error), + ); + }); + + return Scaffold( + body: SafeArea( + child: requestAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _Centered( + child: Text( + (error is Failure) ? error.message : "Couldn't load this request", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.error), + ), + ), + data: (request) { + if (request == null) { + return const _Centered( + child: Text("This request could not be found"), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "Test Status"), + Row( + children: [ + HlaStatusChip(status: request.status), + const Spacer(), + ], + ), + if (request.appointmentDate != null) ...[ + const Gap(kPadding16), + _AppointmentCard( + date: request.appointmentDate!, + location: request.collectionLocation, + ), + ], + const Gap(kPadding24), + if (request.hasResults) ...[ + AppButton( + label: "View results", + icon: FluentIcons.document_checkmark_24_regular, + onPressed: () => context.router + .pushNamed("${HlaResultsScreen.path}/${request.uid}"), + ), + const Gap(kPadding24), + ], + Text("Journey", style: theme.textTheme.titleMedium), + const Gap(kPadding16), + HlaStatusTimeline( + currentStatus: request.status, + history: request.statusHistory, + ), + const Gap(kPadding16), + if (request.status == HlaTestStatus.requested) + AppButton( + label: "Book a collection appointment", + icon: FluentIcons.calendar_add_24_regular, + onPressed: () => _bookAppointment(context, ref, request, user), + ), + if (request.status.isPatientCancellable) ...[ + const Gap(kPadding8), + AppButton( + label: "Cancel request", + buttonType: ButtonType.text, + color: theme.colorScheme.error, + onPressed: () => _confirmCancel(context, ref, request, user), + ), + ], + const Gap(kPadding64), + ], + ), + ); + }, + ), + ), + ); + } + + Future _bookAppointment( + BuildContext context, + WidgetRef ref, + HlaTestRequest request, + AppUser user, + ) async { + final slot = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const _SlotPickerSheet(), + ); + if (slot != null) { + await ref + .read(hlaRequestsNotifierProviderImpl.notifier) + .bookAppointment(request: request, slot: slot, user: user); + } + } + + Future _confirmCancel( + BuildContext context, + WidgetRef ref, + HlaTestRequest request, + AppUser user, + ) async { + final bool? confirmed = await showDialog( + context: context, + builder: (context) => AppAlertDialog( + title: "Cancel request?", + message: + "This will cancel your HLA typing request and free any booked " + "appointment slot. This cannot be undone.", + actions: [ + AppButton( + label: "Keep request", + buttonType: ButtonType.outline, + onPressed: () => Navigator.pop(context, false), + ), + const Gap(kPadding8), + AppButton( + label: "Cancel request", + backgroundColor: Theme.of(context).colorScheme.error, + onPressed: () => Navigator.pop(context, true), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(hlaRequestsNotifierProviderImpl.notifier) + .cancelRequest(request: request, user: user); + } + } +} + +class _AppointmentCard extends StatelessWidget { + const _AppointmentCard({required this.date, this.location}); + + final DateTime date; + final String? location; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + children: [ + Icon( + FluentIcons.calendar_checkmark_24_regular, + color: theme.colorScheme.onPrimaryContainer, + ), + const Gap(kPadding12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Collection appointment", + style: AppTextStyles.bodySmall.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const Gap(kPadding4), + Text( + date.toLocal().formatDateWithTime(context), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + if (location != null) + Text( + location!, + style: AppTextStyles.bodySmall.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Bottom sheet listing available slots; pops with the chosen slot. +class _SlotPickerSheet extends ConsumerStatefulWidget { + const _SlotPickerSheet(); + + @override + ConsumerState<_SlotPickerSheet> createState() => _SlotPickerSheetState(); +} + +class _SlotPickerSheetState extends ConsumerState<_SlotPickerSheet> { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(hlaSlotsNotifierProviderImpl.notifier).getAvailableSlots(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final slotsAsync = ref.watch(hlaSlotsNotifierProviderImpl); + + return Container( + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surfaceContainerLowest + : theme.scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(20, kPadding24, 20, kPadding32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Pick a collection slot", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Gap(kPadding16), + slotsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(kPadding24), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Text( + (error is Failure) ? error.message : "Couldn't load slots", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.error), + ), + data: (slots) { + if (slots.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: kPadding24), + child: Text( + "No appointment slots are available right now. " + "Please check back soon.", + style: AppTextStyles.bodyMedium + .copyWith(color: AppColours.neutral50), + ), + ); + } + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: slots.length, + separatorBuilder: (_, __) => const Gap(kPadding8), + itemBuilder: (context, index) => HlaSlotTile( + slot: slots[index], + onTap: () => Navigator.pop(context, slots[index]), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _Centered extends StatelessWidget { + const _Centered({required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(kPadding24), + child: child, + ), + ); + } +} From 487421dceddbd2e6db937b47a264be990b4dc94a Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:52 +0100 Subject: [PATCH 10/33] add hla admin screens - request queue with status filter - request detail: advance status with note, view panel, upload result document - appointment slot management (create + activate/deactivate) - gated on role == admin at the entry point --- .../admin/hla_admin_detail_screen.dart | 196 +++++++++++++ .../screens/admin/hla_admin_list_screen.dart | 202 +++++++++++++ .../screens/admin/hla_admin_slots_screen.dart | 274 ++++++++++++++++++ 3 files changed, 672 insertions(+) create mode 100644 lib/features/hla/screens/admin/hla_admin_detail_screen.dart create mode 100644 lib/features/hla/screens/admin/hla_admin_list_screen.dart create mode 100644 lib/features/hla/screens/admin/hla_admin_slots_screen.dart diff --git a/lib/features/hla/screens/admin/hla_admin_detail_screen.dart b/lib/features/hla/screens/admin/hla_admin_detail_screen.dart new file mode 100644 index 0000000..f389c44 --- /dev/null +++ b/lib/features/hla/screens/admin/hla_admin_detail_screen.dart @@ -0,0 +1,196 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gap/gap.dart'; + +import '../../../../components/components.dart'; +import '../../../../core/core.dart'; +import '../../hla.dart'; + +/// Admin: manage a single request — advance status with a note, view the +/// subject panel, and upload results (US-8, US-10). +@RoutePage(name: HlaAdminDetailScreen.name) +class HlaAdminDetailScreen extends ConsumerStatefulWidget { + static const String path = "/hla_admin_detail"; + static const String name = "HlaAdminDetailScreen"; + + const HlaAdminDetailScreen({ + super.key, + @PathParam('requestId') required this.requestId, + }); + + final String requestId; + + @override + ConsumerState createState() => + _HlaAdminDetailScreenState(); +} + +class _HlaAdminDetailScreenState extends ConsumerState { + HlaTestStatus? _targetStatus; + final TextEditingController _noteController = TextEditingController(); + final TextEditingController _summaryController = TextEditingController(); + + @override + void dispose() { + _noteController.dispose(); + _summaryController.dispose(); + super.dispose(); + } + + HlaTestRequest? _find(List requests) { + for (final r in requests) { + if (r.uid == widget.requestId) return r; + } + return null; + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final adminAsync = ref.watch(hlaAdminNotifierProviderImpl); + + ref.listen(hlaAdminNotifierProviderImpl, (previous, next) { + next.whenOrNull( + error: (error, _) => + showCustomSnackBar(context: context, error: error), + ); + }); + + final HlaTestRequest? request = _find(adminAsync.value ?? const []); + final bool isBusy = adminAsync.isLoading; + + return Scaffold( + body: SafeArea( + child: request == null + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CustomAppBar(pageTitle: "Request"), + Row( + children: [ + Expanded( + child: Text( + request.patientName.isEmpty + ? "Unknown patient" + : request.patientName, + style: theme.textTheme.titleLarge, + ), + ), + HlaStatusChip(status: request.status), + ], + ), + const Gap(kPadding24), + + // ── Advance status ── + Text("Advance status", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + DropdownButtonFormField( + initialValue: _targetStatus ?? request.status.next, + isExpanded: true, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "New status"), + items: [ + for (final status in HlaTestStatus.values) + DropdownMenuItem( + value: status, + child: Text(status.label), + ), + ], + onChanged: (value) => + setState(() => _targetStatus = value), + ), + const Gap(kPadding12), + TextField( + controller: _noteController, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Note (optional)"), + ), + const Gap(kPadding12), + AppButton( + label: "Update status", + isLoading: isBusy, + onPressed: () { + final status = _targetStatus ?? request.status.next; + if (status == null) return; + ref + .read(hlaAdminNotifierProviderImpl.notifier) + .advanceStatus( + request: request, + status: status, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + _noteController.clear(); + }, + ), + const Gap(kPadding32), + + // ── Subjects ── + Text("Sample panel", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: request.subjects.length, + separatorBuilder: (_, __) => const Gap(kPadding8), + itemBuilder: (context, index) => + HlaSubjectTile(subject: request.subjects[index]), + ), + const Gap(kPadding32), + + // ── Results ── + Text("Results", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + if (request.resultFileName != null) + Text( + "Uploaded: ${request.resultFileName}", + style: AppTextStyles.bodyMedium + .copyWith(color: AppColours.green60), + ), + const Gap(kPadding8), + TextField( + controller: _summaryController, + maxLines: 3, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Result summary (optional)"), + ), + const Gap(kPadding12), + AppButton( + label: "Upload result document", + buttonType: ButtonType.outline, + isLoading: isBusy, + onPressed: () => _pickAndUpload(request), + ), + const Gap(kPadding64), + ], + ), + ), + ), + ); + } + + Future _pickAndUpload(HlaTestRequest request) async { + final FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const ["pdf", "jpg", "jpeg", "png"], + ); + final path = result?.files.single.path; + final fileName = result?.files.single.name; + if (path == null || fileName == null) return; + + await ref.read(hlaAdminNotifierProviderImpl.notifier).uploadResult( + request: request, + filePath: path, + fileName: fileName, + summary: _summaryController.text.trim().isEmpty + ? null + : _summaryController.text.trim(), + ); + } +} diff --git a/lib/features/hla/screens/admin/hla_admin_list_screen.dart b/lib/features/hla/screens/admin/hla_admin_list_screen.dart new file mode 100644 index 0000000..9fbd72f --- /dev/null +++ b/lib/features/hla/screens/admin/hla_admin_list_screen.dart @@ -0,0 +1,202 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../../components/components.dart'; +import '../../../../core/core.dart'; +import '../../../../core/extensions/date_time_formatter.dart'; +import '../../hla.dart'; + +/// Admin: the full request queue, filterable by status (US-7). Role-gated at +/// the entry point and enforced by Firestore rules. +@RoutePage(name: HlaAdminListScreen.name) +class HlaAdminListScreen extends ConsumerStatefulWidget { + static const String path = "/hla_admin"; + static const String name = "HlaAdminListScreen"; + const HlaAdminListScreen({super.key}); + + @override + ConsumerState createState() => _HlaAdminListScreenState(); +} + +class _HlaAdminListScreenState extends ConsumerState { + HlaTestStatus? _filter; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(hlaAdminNotifierProviderImpl.notifier).getAllRequests(); + }); + super.initState(); + } + + void _applyFilter(HlaTestStatus? status) { + setState(() => _filter = status); + ref.read(hlaAdminNotifierProviderImpl.notifier).getAllRequests(status: status); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final requestsAsync = ref.watch(hlaAdminNotifierProviderImpl); + + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () => + context.router.pushNamed(HlaAdminSlotsScreen.path), + icon: const Icon(FluentIcons.calendar_24_regular), + label: const Text("Slots"), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: kPadding16), + child: CustomAppBar(pageTitle: "HLA Admin"), + ), + SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + children: [ + _FilterChip( + label: "All", + selected: _filter == null, + onTap: () => _applyFilter(null), + ), + for (final status in HlaTestStatus.values) + Padding( + padding: const EdgeInsets.only(left: kPadding8), + child: _FilterChip( + label: status.label, + selected: _filter == status, + onTap: () => _applyFilter(status), + ), + ), + ], + ), + ), + const Gap(kPadding16), + Expanded( + child: requestsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + (error is Failure) ? error.message : "Couldn't load queue", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.error), + ), + ), + data: (requests) { + if (requests.isEmpty) { + return Center( + child: Text( + "No requests", + style: AppTextStyles.bodyLarge + .copyWith(color: AppColours.neutral50), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + itemCount: requests.length, + separatorBuilder: (_, __) => const Gap(kPadding12), + itemBuilder: (context, index) { + final request = requests[index]; + return InkWell( + borderRadius: BorderRadius.circular(kPadding16), + onTap: () => context.router.pushNamed( + "${HlaAdminDetailScreen.path}/${request.uid}", + ), + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + request.patientName.isEmpty + ? "Unknown patient" + : request.patientName, + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + HlaStatusChip(status: request.status), + ], + ), + const Gap(kPadding8), + Text( + request.appointmentDate != null + ? request.appointmentDate! + .toLocal() + .formatDateWithTime(context) + : "No appointment", + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return GestureDetector( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding24), + ), + child: Text( + label, + style: AppTextStyles.bodySmall.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/features/hla/screens/admin/hla_admin_slots_screen.dart b/lib/features/hla/screens/admin/hla_admin_slots_screen.dart new file mode 100644 index 0000000..8145bf5 --- /dev/null +++ b/lib/features/hla/screens/admin/hla_admin_slots_screen.dart @@ -0,0 +1,274 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:gap/gap.dart'; + +import '../../../../components/components.dart'; +import '../../../../core/core.dart'; +import '../../../../core/extensions/date_time_formatter.dart'; +import '../../hla.dart'; + +/// Admin: define and manage bookable collection slots (US-9). +@RoutePage(name: HlaAdminSlotsScreen.name) +class HlaAdminSlotsScreen extends ConsumerStatefulWidget { + static const String path = "/hla_admin_slots"; + static const String name = "HlaAdminSlotsScreen"; + const HlaAdminSlotsScreen({super.key}); + + @override + ConsumerState createState() => + _HlaAdminSlotsScreenState(); +} + +class _HlaAdminSlotsScreenState extends ConsumerState { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(hlaAdminSlotsNotifierProviderImpl.notifier).getAllSlots(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final slotsAsync = ref.watch(hlaAdminSlotsNotifierProviderImpl); + + ref.listen(hlaAdminSlotsNotifierProviderImpl, (previous, next) { + next.whenOrNull( + error: (error, _) => + showCustomSnackBar(context: context, error: error), + ); + }); + + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showAddSlotSheet(context), + icon: const Icon(FluentIcons.add_24_regular), + label: const Text("Add slot"), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: kPadding16), + child: CustomAppBar(pageTitle: "Appointment Slots"), + ), + Expanded( + child: slotsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + (error is Failure) ? error.message : "Couldn't load slots", + style: AppTextStyles.bodyMedium + .copyWith(color: theme.colorScheme.error), + ), + ), + data: (slots) { + if (slots.isEmpty) { + return Center( + child: Text( + "No slots yet — add one", + style: AppTextStyles.bodyLarge + .copyWith(color: AppColours.neutral50), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: kPadding16), + itemCount: slots.length, + separatorBuilder: (_, __) => const Gap(kPadding12), + itemBuilder: (context, index) { + final slot = slots[index]; + return Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slot.dateTime + .toLocal() + .formatDateWithTime(context), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Gap(kPadding4), + Text( + "${slot.location} · ${slot.bookedCount}/${slot.capacity} booked", + style: AppTextStyles.bodySmall + .copyWith(color: AppColours.neutral50), + ), + ], + ), + ), + Switch( + value: slot.isActive, + onChanged: (value) { + ref + .read(hlaAdminSlotsNotifierProviderImpl + .notifier) + .putSlot( + slot: slot.copyWith(isActive: value), + ); + }, + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } + + Future _showAddSlotSheet(BuildContext context) async { + final slot = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const _AddSlotSheet(), + ); + if (slot != null) { + await ref + .read(hlaAdminSlotsNotifierProviderImpl.notifier) + .putSlot(slot: slot); + } + } +} + +class _AddSlotSheet extends StatefulWidget { + const _AddSlotSheet(); + + @override + State<_AddSlotSheet> createState() => _AddSlotSheetState(); +} + +class _AddSlotSheetState extends State<_AddSlotSheet> { + final TextEditingController _locationController = TextEditingController(); + final TextEditingController _capacityController = + TextEditingController(text: "10"); + DateTime? _dateTime; + String? _error; + + @override + void dispose() { + _locationController.dispose(); + _capacityController.dispose(); + super.dispose(); + } + + Future _pickDateTime() async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + initialDate: now, + ); + if (date == null || !mounted) return; + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time == null) return; + setState(() { + _dateTime = + DateTime(date.year, date.month, date.day, time.hour, time.minute); + }); + } + + void _submit() { + final int capacity = int.tryParse(_capacityController.text.trim()) ?? 0; + if (_dateTime == null) { + setState(() => _error = "Pick a date and time"); + return; + } + if (_locationController.text.trim().isEmpty) { + setState(() => _error = "Location is required"); + return; + } + if (capacity <= 0) { + setState(() => _error = "Capacity must be at least 1"); + return; + } + Navigator.pop( + context, + HlaAppointmentSlot( + id: "", + dateTime: _dateTime!, + location: _locationController.text.trim(), + capacity: capacity, + ), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return AppBottomSheet( + title: "Add appointment slot", + buttonLabel: "Save slot", + onPressed: _submit, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + borderRadius: BorderRadius.circular(kPadding12), + onTap: _pickDateTime, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding12), + ), + child: Text( + _dateTime == null + ? "Pick date & time" + : _dateTime!.formatDateWithTime(context), + style: theme.textTheme.bodyLarge, + ), + ), + ), + const Gap(kPadding16), + TextField( + controller: _locationController, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Location"), + ), + const Gap(kPadding16), + TextField( + controller: _capacityController, + keyboardType: TextInputType.number, + decoration: AppInputDecoration.inputDecoration(context) + .copyWith(hintText: "Capacity"), + ), + if (_error != null) ...[ + const Gap(kPadding8), + Text( + _error!, + style: AppTextStyles.bodySmall + .copyWith(color: theme.colorScheme.error), + ), + ], + ], + ), + ); + } +} From 50b47de309119564ddc99d90cca5ba17ccb6a95a Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:51:52 +0100 Subject: [PATCH 11/33] add hla feature barrel - exports models, services, repository, providers and screens --- lib/features/hla/hla.dart | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 lib/features/hla/hla.dart diff --git a/lib/features/hla/hla.dart b/lib/features/hla/hla.dart new file mode 100644 index 0000000..61dfc13 --- /dev/null +++ b/lib/features/hla/hla.dart @@ -0,0 +1,30 @@ +// Models +export 'models/hla_appointment_slot.dart'; +export 'models/hla_request_form_state.dart'; +export 'models/hla_sample_subject.dart'; +export 'models/hla_status_event.dart'; +export 'models/hla_test_request.dart'; + +// Services +export 'services/local/hla_local_service.dart'; +export 'services/remote/hla_service.dart'; + +// Repository +export 'repositories/hla_repository.dart'; + +// Providers +export 'providers/hla_admin_notifier.dart'; +export 'providers/hla_request_form_notifier.dart'; +export 'providers/hla_requests_notifier.dart'; +export 'providers/hla_slots_notifier.dart'; + +// Screens +export 'screens/hla_intro_screen.dart'; +export 'screens/hla_request_form_screen.dart'; +export 'screens/hla_requests_list_screen.dart'; +export 'screens/hla_status_screen.dart'; +export 'screens/hla_results_screen.dart'; +export 'screens/components/components.dart'; +export 'screens/admin/hla_admin_list_screen.dart'; +export 'screens/admin/hla_admin_detail_screen.dart'; +export 'screens/admin/hla_admin_slots_screen.dart'; From b74e3bb8251997da4a3fd361313cee05b80024f1 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:06 +0100 Subject: [PATCH 12/33] register hla routes - add patient and admin hla routes to the router - status/results/admin-detail take a :requestId path param - regenerate routes.gr.dart --- lib/core/routes/routes.dart | 33 +++ lib/core/routes/routes.gr.dart | 498 ++++++++++++++++++++++++--------- 2 files changed, 394 insertions(+), 137 deletions(-) diff --git a/lib/core/routes/routes.dart b/lib/core/routes/routes.dart index 866cddb..09e8d60 100644 --- a/lib/core/routes/routes.dart +++ b/lib/core/routes/routes.dart @@ -3,6 +3,7 @@ import '../../components/bottom_nav_bar.dart'; import '../../features/auth/auth.dart'; import '../../features/emergency/emergency.dart'; import '../../features/health_connect/health_connect.dart'; +import '../../features/hla/hla.dart'; import '../../features/home/home.dart'; import '../../features/meds/meds.dart'; import '../../features/profile/profile.dart'; @@ -34,6 +35,38 @@ class AppRouter extends RootStackRouter { // ////-------Health Connect-------//// // AutoRoute(page: route.HealthScreen.page, path: HealthScreen.path), + // ////-------HLA Typing-------//// // + AutoRoute(page: route.HlaIntroScreen.page, path: HlaIntroScreen.path), + AutoRoute( + page: route.HlaRequestFormScreen.page, + path: HlaRequestFormScreen.path, + ), + AutoRoute( + page: route.HlaRequestsListScreen.page, + path: HlaRequestsListScreen.path, + ), + AutoRoute( + page: route.HlaStatusScreen.page, + path: "${HlaStatusScreen.path}/:requestId", + ), + AutoRoute( + page: route.HlaResultsScreen.page, + path: "${HlaResultsScreen.path}/:requestId", + ), + // HLA admin (role-gated at the entry point) + AutoRoute( + page: route.HlaAdminListScreen.page, + path: HlaAdminListScreen.path, + ), + AutoRoute( + page: route.HlaAdminDetailScreen.page, + path: "${HlaAdminDetailScreen.path}/:requestId", + ), + AutoRoute( + page: route.HlaAdminSlotsScreen.page, + path: HlaAdminSlotsScreen.path, + ), + // ////-------Emergency-------//// // AutoRoute(page: route.EmergencyScreen.page, path: EmergencyScreen.path), AutoRoute( diff --git a/lib/core/routes/routes.gr.dart b/lib/core/routes/routes.gr.dart index d20120d..d251b42 100644 --- a/lib/core/routes/routes.gr.dart +++ b/lib/core/routes/routes.gr.dart @@ -9,16 +9,16 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i26; +import 'package:auto_route/auto_route.dart' as _i34; import 'package:circle/components/bottom_nav_bar.dart' as _i4; import 'package:circle/features/auth/screens/auth/auth_success.dart' as _i3; import 'package:circle/features/auth/screens/auth/google_sign_in_screen.dart' as _i8; -import 'package:circle/features/auth/screens/auth/loading_screen.dart' as _i11; -import 'package:circle/features/auth/screens/auth/register_screen.dart' as _i20; -import 'package:circle/features/auth/screens/auth/sign_in_screen.dart' as _i22; +import 'package:circle/features/auth/screens/auth/loading_screen.dart' as _i19; +import 'package:circle/features/auth/screens/auth/register_screen.dart' as _i28; +import 'package:circle/features/auth/screens/auth/sign_in_screen.dart' as _i30; import 'package:circle/features/auth/screens/onboarding/onboarding_base_screen.dart' - as _i15; + as _i23; import 'package:circle/features/emergency/screens/add_emergency_contact_screen.dart' as _i1; import 'package:circle/features/emergency/screens/crisis_logs_screen.dart' @@ -26,38 +26,51 @@ import 'package:circle/features/emergency/screens/crisis_logs_screen.dart' import 'package:circle/features/emergency/screens/emergency_screen.dart' as _i7; import 'package:circle/features/health_connect/screens/health_screen.dart' as _i9; -import 'package:circle/features/home/screens/home_screen.dart' as _i10; -import 'package:circle/features/meds/models/medication.dart' as _i28; +import 'package:circle/features/hla/screens/admin/hla_admin_detail_screen.dart' + as _i10; +import 'package:circle/features/hla/screens/admin/hla_admin_list_screen.dart' + as _i11; +import 'package:circle/features/hla/screens/admin/hla_admin_slots_screen.dart' + as _i12; +import 'package:circle/features/hla/screens/hla_intro_screen.dart' as _i13; +import 'package:circle/features/hla/screens/hla_request_form_screen.dart' + as _i14; +import 'package:circle/features/hla/screens/hla_requests_list_screen.dart' + as _i15; +import 'package:circle/features/hla/screens/hla_results_screen.dart' as _i16; +import 'package:circle/features/hla/screens/hla_status_screen.dart' as _i17; +import 'package:circle/features/home/screens/home_screen.dart' as _i18; +import 'package:circle/features/meds/models/medication.dart' as _i36; import 'package:circle/features/meds/screens/add_edit_meds_screen.dart' as _i2; -import 'package:circle/features/meds/screens/meds_details_screen.dart' as _i12; -import 'package:circle/features/meds/screens/meds_schedule_screen.dart' as _i13; -import 'package:circle/features/meds/screens/meds_screen.dart' as _i14; +import 'package:circle/features/meds/screens/meds_details_screen.dart' as _i20; +import 'package:circle/features/meds/screens/meds_schedule_screen.dart' as _i21; +import 'package:circle/features/meds/screens/meds_screen.dart' as _i22; import 'package:circle/features/profile/screens/profile_basic_info_screen.dart' - as _i16; + as _i24; import 'package:circle/features/profile/screens/profile_medical_info_screen.dart' - as _i17; -import 'package:circle/features/profile/screens/profile_screen.dart' as _i18; + as _i25; +import 'package:circle/features/profile/screens/profile_screen.dart' as _i26; import 'package:circle/features/profile/screens/profile_vitals_info_screen.dart' - as _i19; -import 'package:circle/features/profile/screens/settings_screen.dart' as _i21; + as _i27; +import 'package:circle/features/profile/screens/settings_screen.dart' as _i29; import 'package:circle/features/water/screens/edit_daily_goal_screen.dart' as _i6; import 'package:circle/features/water/screens/suggested_water_daily_goal_screen.dart' - as _i23; -import 'package:circle/features/water/screens/water_empty_screen.dart' as _i24; -import 'package:circle/features/water/screens/water_screen.dart' as _i25; -import 'package:flutter/cupertino.dart' as _i27; -import 'package:flutter/material.dart' as _i29; + as _i31; +import 'package:circle/features/water/screens/water_empty_screen.dart' as _i32; +import 'package:circle/features/water/screens/water_screen.dart' as _i33; +import 'package:flutter/cupertino.dart' as _i35; +import 'package:flutter/material.dart' as _i37; /// generated route for /// [_i1.AddEmergencyContactScreen] -class AddEmergencyContactScreen extends _i26.PageRouteInfo { - const AddEmergencyContactScreen({List<_i26.PageRouteInfo>? children}) +class AddEmergencyContactScreen extends _i34.PageRouteInfo { + const AddEmergencyContactScreen({List<_i34.PageRouteInfo>? children}) : super(AddEmergencyContactScreen.name, initialChildren: children); static const String name = 'AddEmergencyContactScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i1.AddEmergencyContactScreen(); @@ -67,12 +80,12 @@ class AddEmergencyContactScreen extends _i26.PageRouteInfo { /// generated route for /// [_i2.AddMedsScreen] -class AddMedsScreen extends _i26.PageRouteInfo { +class AddMedsScreen extends _i34.PageRouteInfo { AddMedsScreen({ - _i27.Key? key, + _i35.Key? key, bool isEditing = false, - _i28.Medication? medicationToEdit, - List<_i26.PageRouteInfo>? children, + _i36.Medication? medicationToEdit, + List<_i34.PageRouteInfo>? children, }) : super( AddMedsScreen.name, args: AddMedsScreenArgs( @@ -85,7 +98,7 @@ class AddMedsScreen extends _i26.PageRouteInfo { static const String name = 'AddMedsScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -107,11 +120,11 @@ class AddMedsScreenArgs { this.medicationToEdit, }); - final _i27.Key? key; + final _i35.Key? key; final bool isEditing; - final _i28.Medication? medicationToEdit; + final _i36.Medication? medicationToEdit; @override String toString() { @@ -121,13 +134,13 @@ class AddMedsScreenArgs { /// generated route for /// [_i3.AuthSuccessScreen] -class AuthSuccessScreen extends _i26.PageRouteInfo { - const AuthSuccessScreen({List<_i26.PageRouteInfo>? children}) +class AuthSuccessScreen extends _i34.PageRouteInfo { + const AuthSuccessScreen({List<_i34.PageRouteInfo>? children}) : super(AuthSuccessScreen.name, initialChildren: children); static const String name = 'AuthSuccessScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i3.AuthSuccessScreen(); @@ -137,13 +150,13 @@ class AuthSuccessScreen extends _i26.PageRouteInfo { /// generated route for /// [_i4.BottomNavBar] -class BottomNavBar extends _i26.PageRouteInfo { - const BottomNavBar({List<_i26.PageRouteInfo>? children}) +class BottomNavBar extends _i34.PageRouteInfo { + const BottomNavBar({List<_i34.PageRouteInfo>? children}) : super(BottomNavBar.name, initialChildren: children); static const String name = 'BottomNavBar'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i4.BottomNavBar(); @@ -153,13 +166,13 @@ class BottomNavBar extends _i26.PageRouteInfo { /// generated route for /// [_i5.CrisisLogsScreen] -class CrisisLogsScreen extends _i26.PageRouteInfo { - const CrisisLogsScreen({List<_i26.PageRouteInfo>? children}) +class CrisisLogsScreen extends _i34.PageRouteInfo { + const CrisisLogsScreen({List<_i34.PageRouteInfo>? children}) : super(CrisisLogsScreen.name, initialChildren: children); static const String name = 'CrisisLogsScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i5.CrisisLogsScreen(); @@ -169,13 +182,13 @@ class CrisisLogsScreen extends _i26.PageRouteInfo { /// generated route for /// [_i6.EditDailyGoalScreen] -class EditDailyGoalScreen extends _i26.PageRouteInfo { - const EditDailyGoalScreen({List<_i26.PageRouteInfo>? children}) +class EditDailyGoalScreen extends _i34.PageRouteInfo { + const EditDailyGoalScreen({List<_i34.PageRouteInfo>? children}) : super(EditDailyGoalScreen.name, initialChildren: children); static const String name = 'EditDailyGoalScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i6.EditDailyGoalScreen(); @@ -185,13 +198,13 @@ class EditDailyGoalScreen extends _i26.PageRouteInfo { /// generated route for /// [_i7.EmergencyScreen] -class EmergencyScreen extends _i26.PageRouteInfo { - const EmergencyScreen({List<_i26.PageRouteInfo>? children}) +class EmergencyScreen extends _i34.PageRouteInfo { + const EmergencyScreen({List<_i34.PageRouteInfo>? children}) : super(EmergencyScreen.name, initialChildren: children); static const String name = 'EmergencyScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i7.EmergencyScreen(); @@ -201,13 +214,13 @@ class EmergencyScreen extends _i26.PageRouteInfo { /// generated route for /// [_i8.GoogleSignInScreen] -class GoogleSignInScreen extends _i26.PageRouteInfo { - const GoogleSignInScreen({List<_i26.PageRouteInfo>? children}) +class GoogleSignInScreen extends _i34.PageRouteInfo { + const GoogleSignInScreen({List<_i34.PageRouteInfo>? children}) : super(GoogleSignInScreen.name, initialChildren: children); static const String name = 'GoogleSignInScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i8.GoogleSignInScreen(); @@ -217,13 +230,13 @@ class GoogleSignInScreen extends _i26.PageRouteInfo { /// generated route for /// [_i9.HealthScreen] -class HealthScreen extends _i26.PageRouteInfo { - const HealthScreen({List<_i26.PageRouteInfo>? children}) +class HealthScreen extends _i34.PageRouteInfo { + const HealthScreen({List<_i34.PageRouteInfo>? children}) : super(HealthScreen.name, initialChildren: children); static const String name = 'HealthScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { return const _i9.HealthScreen(); @@ -232,109 +245,320 @@ class HealthScreen extends _i26.PageRouteInfo { } /// generated route for -/// [_i10.HomeScreen] -class HomeScreen extends _i26.PageRouteInfo { - const HomeScreen({List<_i26.PageRouteInfo>? children}) +/// [_i10.HlaAdminDetailScreen] +class HlaAdminDetailScreen + extends _i34.PageRouteInfo { + HlaAdminDetailScreen({ + _i37.Key? key, + required String requestId, + List<_i34.PageRouteInfo>? children, + }) : super( + HlaAdminDetailScreen.name, + args: HlaAdminDetailScreenArgs(key: key, requestId: requestId), + rawPathParams: {'requestId': requestId}, + initialChildren: children, + ); + + static const String name = 'HlaAdminDetailScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => HlaAdminDetailScreenArgs( + requestId: pathParams.getString('requestId'), + ), + ); + return _i10.HlaAdminDetailScreen( + key: args.key, + requestId: args.requestId, + ); + }, + ); +} + +class HlaAdminDetailScreenArgs { + const HlaAdminDetailScreenArgs({this.key, required this.requestId}); + + final _i37.Key? key; + + final String requestId; + + @override + String toString() { + return 'HlaAdminDetailScreenArgs{key: $key, requestId: $requestId}'; + } +} + +/// generated route for +/// [_i11.HlaAdminListScreen] +class HlaAdminListScreen extends _i34.PageRouteInfo { + const HlaAdminListScreen({List<_i34.PageRouteInfo>? children}) + : super(HlaAdminListScreen.name, initialChildren: children); + + static const String name = 'HlaAdminListScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + return const _i11.HlaAdminListScreen(); + }, + ); +} + +/// generated route for +/// [_i12.HlaAdminSlotsScreen] +class HlaAdminSlotsScreen extends _i34.PageRouteInfo { + const HlaAdminSlotsScreen({List<_i34.PageRouteInfo>? children}) + : super(HlaAdminSlotsScreen.name, initialChildren: children); + + static const String name = 'HlaAdminSlotsScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + return const _i12.HlaAdminSlotsScreen(); + }, + ); +} + +/// generated route for +/// [_i13.HlaIntroScreen] +class HlaIntroScreen extends _i34.PageRouteInfo { + const HlaIntroScreen({List<_i34.PageRouteInfo>? children}) + : super(HlaIntroScreen.name, initialChildren: children); + + static const String name = 'HlaIntroScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + return const _i13.HlaIntroScreen(); + }, + ); +} + +/// generated route for +/// [_i14.HlaRequestFormScreen] +class HlaRequestFormScreen extends _i34.PageRouteInfo { + const HlaRequestFormScreen({List<_i34.PageRouteInfo>? children}) + : super(HlaRequestFormScreen.name, initialChildren: children); + + static const String name = 'HlaRequestFormScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + return const _i14.HlaRequestFormScreen(); + }, + ); +} + +/// generated route for +/// [_i15.HlaRequestsListScreen] +class HlaRequestsListScreen extends _i34.PageRouteInfo { + const HlaRequestsListScreen({List<_i34.PageRouteInfo>? children}) + : super(HlaRequestsListScreen.name, initialChildren: children); + + static const String name = 'HlaRequestsListScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + return const _i15.HlaRequestsListScreen(); + }, + ); +} + +/// generated route for +/// [_i16.HlaResultsScreen] +class HlaResultsScreen extends _i34.PageRouteInfo { + HlaResultsScreen({ + _i37.Key? key, + required String requestId, + List<_i34.PageRouteInfo>? children, + }) : super( + HlaResultsScreen.name, + args: HlaResultsScreenArgs(key: key, requestId: requestId), + rawPathParams: {'requestId': requestId}, + initialChildren: children, + ); + + static const String name = 'HlaResultsScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => + HlaResultsScreenArgs(requestId: pathParams.getString('requestId')), + ); + return _i16.HlaResultsScreen(key: args.key, requestId: args.requestId); + }, + ); +} + +class HlaResultsScreenArgs { + const HlaResultsScreenArgs({this.key, required this.requestId}); + + final _i37.Key? key; + + final String requestId; + + @override + String toString() { + return 'HlaResultsScreenArgs{key: $key, requestId: $requestId}'; + } +} + +/// generated route for +/// [_i17.HlaStatusScreen] +class HlaStatusScreen extends _i34.PageRouteInfo { + HlaStatusScreen({ + _i37.Key? key, + required String requestId, + List<_i34.PageRouteInfo>? children, + }) : super( + HlaStatusScreen.name, + args: HlaStatusScreenArgs(key: key, requestId: requestId), + rawPathParams: {'requestId': requestId}, + initialChildren: children, + ); + + static const String name = 'HlaStatusScreen'; + + static _i34.PageInfo page = _i34.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => + HlaStatusScreenArgs(requestId: pathParams.getString('requestId')), + ); + return _i17.HlaStatusScreen(key: args.key, requestId: args.requestId); + }, + ); +} + +class HlaStatusScreenArgs { + const HlaStatusScreenArgs({this.key, required this.requestId}); + + final _i37.Key? key; + + final String requestId; + + @override + String toString() { + return 'HlaStatusScreenArgs{key: $key, requestId: $requestId}'; + } +} + +/// generated route for +/// [_i18.HomeScreen] +class HomeScreen extends _i34.PageRouteInfo { + const HomeScreen({List<_i34.PageRouteInfo>? children}) : super(HomeScreen.name, initialChildren: children); static const String name = 'HomeScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i10.HomeScreen(); + return const _i18.HomeScreen(); }, ); } /// generated route for -/// [_i11.LoadingScreen] -class LoadingScreen extends _i26.PageRouteInfo { - const LoadingScreen({List<_i26.PageRouteInfo>? children}) +/// [_i19.LoadingScreen] +class LoadingScreen extends _i34.PageRouteInfo { + const LoadingScreen({List<_i34.PageRouteInfo>? children}) : super(LoadingScreen.name, initialChildren: children); static const String name = 'LoadingScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i11.LoadingScreen(); + return const _i19.LoadingScreen(); }, ); } /// generated route for -/// [_i12.MedsDetailsScreen] -class MedsDetailsScreen extends _i26.PageRouteInfo { - const MedsDetailsScreen({List<_i26.PageRouteInfo>? children}) +/// [_i20.MedsDetailsScreen] +class MedsDetailsScreen extends _i34.PageRouteInfo { + const MedsDetailsScreen({List<_i34.PageRouteInfo>? children}) : super(MedsDetailsScreen.name, initialChildren: children); static const String name = 'MedsDetailsScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i12.MedsDetailsScreen(); + return const _i20.MedsDetailsScreen(); }, ); } /// generated route for -/// [_i13.MedsScheduleScreen] -class MedsScheduleScreen extends _i26.PageRouteInfo { - const MedsScheduleScreen({List<_i26.PageRouteInfo>? children}) +/// [_i21.MedsScheduleScreen] +class MedsScheduleScreen extends _i34.PageRouteInfo { + const MedsScheduleScreen({List<_i34.PageRouteInfo>? children}) : super(MedsScheduleScreen.name, initialChildren: children); static const String name = 'MedsScheduleScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i13.MedsScheduleScreen(); + return const _i21.MedsScheduleScreen(); }, ); } /// generated route for -/// [_i14.MedsScreen] -class MedsScreen extends _i26.PageRouteInfo { - const MedsScreen({List<_i26.PageRouteInfo>? children}) +/// [_i22.MedsScreen] +class MedsScreen extends _i34.PageRouteInfo { + const MedsScreen({List<_i34.PageRouteInfo>? children}) : super(MedsScreen.name, initialChildren: children); static const String name = 'MedsScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i14.MedsScreen(); + return const _i22.MedsScreen(); }, ); } /// generated route for -/// [_i15.OnboardingBaseScreen] -class OnboardingBaseScreen extends _i26.PageRouteInfo { - const OnboardingBaseScreen({List<_i26.PageRouteInfo>? children}) +/// [_i23.OnboardingBaseScreen] +class OnboardingBaseScreen extends _i34.PageRouteInfo { + const OnboardingBaseScreen({List<_i34.PageRouteInfo>? children}) : super(OnboardingBaseScreen.name, initialChildren: children); static const String name = 'OnboardingBaseScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i15.OnboardingBaseScreen(); + return const _i23.OnboardingBaseScreen(); }, ); } /// generated route for -/// [_i16.ProfileBasicInfoScreen] +/// [_i24.ProfileBasicInfoScreen] class ProfileBasicInfoScreen - extends _i26.PageRouteInfo { + extends _i34.PageRouteInfo { ProfileBasicInfoScreen({ - _i29.Key? key, + _i37.Key? key, bool? isEditing = false, - List<_i26.PageRouteInfo>? children, + List<_i34.PageRouteInfo>? children, }) : super( ProfileBasicInfoScreen.name, args: ProfileBasicInfoScreenArgs(key: key, isEditing: isEditing), @@ -343,13 +567,13 @@ class ProfileBasicInfoScreen static const String name = 'ProfileBasicInfoScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ProfileBasicInfoScreenArgs(), ); - return _i16.ProfileBasicInfoScreen( + return _i24.ProfileBasicInfoScreen( key: args.key, isEditing: args.isEditing, ); @@ -360,7 +584,7 @@ class ProfileBasicInfoScreen class ProfileBasicInfoScreenArgs { const ProfileBasicInfoScreenArgs({this.key, this.isEditing = false}); - final _i29.Key? key; + final _i37.Key? key; final bool? isEditing; @@ -371,13 +595,13 @@ class ProfileBasicInfoScreenArgs { } /// generated route for -/// [_i17.ProfileMedicalInfoScreen] +/// [_i25.ProfileMedicalInfoScreen] class ProfileMedicalInfoScreen - extends _i26.PageRouteInfo { + extends _i34.PageRouteInfo { ProfileMedicalInfoScreen({ - _i29.Key? key, + _i37.Key? key, bool? isEditing = false, - List<_i26.PageRouteInfo>? children, + List<_i34.PageRouteInfo>? children, }) : super( ProfileMedicalInfoScreen.name, args: ProfileMedicalInfoScreenArgs(key: key, isEditing: isEditing), @@ -386,13 +610,13 @@ class ProfileMedicalInfoScreen static const String name = 'ProfileMedicalInfoScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ProfileMedicalInfoScreenArgs(), ); - return _i17.ProfileMedicalInfoScreen( + return _i25.ProfileMedicalInfoScreen( key: args.key, isEditing: args.isEditing, ); @@ -403,7 +627,7 @@ class ProfileMedicalInfoScreen class ProfileMedicalInfoScreenArgs { const ProfileMedicalInfoScreenArgs({this.key, this.isEditing = false}); - final _i29.Key? key; + final _i37.Key? key; final bool? isEditing; @@ -414,29 +638,29 @@ class ProfileMedicalInfoScreenArgs { } /// generated route for -/// [_i18.ProfileScreen] -class ProfileScreen extends _i26.PageRouteInfo { - const ProfileScreen({List<_i26.PageRouteInfo>? children}) +/// [_i26.ProfileScreen] +class ProfileScreen extends _i34.PageRouteInfo { + const ProfileScreen({List<_i34.PageRouteInfo>? children}) : super(ProfileScreen.name, initialChildren: children); static const String name = 'ProfileScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i18.ProfileScreen(); + return const _i26.ProfileScreen(); }, ); } /// generated route for -/// [_i19.ProfileVitalsInfoScreen] +/// [_i27.ProfileVitalsInfoScreen] class ProfileVitalsInfoScreen - extends _i26.PageRouteInfo { + extends _i34.PageRouteInfo { ProfileVitalsInfoScreen({ - _i29.Key? key, + _i37.Key? key, bool? isEditing = false, - List<_i26.PageRouteInfo>? children, + List<_i34.PageRouteInfo>? children, }) : super( ProfileVitalsInfoScreen.name, args: ProfileVitalsInfoScreenArgs(key: key, isEditing: isEditing), @@ -445,13 +669,13 @@ class ProfileVitalsInfoScreen static const String name = 'ProfileVitalsInfoScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ProfileVitalsInfoScreenArgs(), ); - return _i19.ProfileVitalsInfoScreen( + return _i27.ProfileVitalsInfoScreen( key: args.key, isEditing: args.isEditing, ); @@ -462,7 +686,7 @@ class ProfileVitalsInfoScreen class ProfileVitalsInfoScreenArgs { const ProfileVitalsInfoScreenArgs({this.key, this.isEditing = false}); - final _i29.Key? key; + final _i37.Key? key; final bool? isEditing; @@ -473,97 +697,97 @@ class ProfileVitalsInfoScreenArgs { } /// generated route for -/// [_i20.RegisterScreen] -class RegisterScreen extends _i26.PageRouteInfo { - const RegisterScreen({List<_i26.PageRouteInfo>? children}) +/// [_i28.RegisterScreen] +class RegisterScreen extends _i34.PageRouteInfo { + const RegisterScreen({List<_i34.PageRouteInfo>? children}) : super(RegisterScreen.name, initialChildren: children); static const String name = 'RegisterScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i20.RegisterScreen(); + return const _i28.RegisterScreen(); }, ); } /// generated route for -/// [_i21.SettingsScreen] -class SettingsScreen extends _i26.PageRouteInfo { - const SettingsScreen({List<_i26.PageRouteInfo>? children}) +/// [_i29.SettingsScreen] +class SettingsScreen extends _i34.PageRouteInfo { + const SettingsScreen({List<_i34.PageRouteInfo>? children}) : super(SettingsScreen.name, initialChildren: children); static const String name = 'SettingsScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i21.SettingsScreen(); + return const _i29.SettingsScreen(); }, ); } /// generated route for -/// [_i22.SignInScreen] -class SignInScreen extends _i26.PageRouteInfo { - const SignInScreen({List<_i26.PageRouteInfo>? children}) +/// [_i30.SignInScreen] +class SignInScreen extends _i34.PageRouteInfo { + const SignInScreen({List<_i34.PageRouteInfo>? children}) : super(SignInScreen.name, initialChildren: children); static const String name = 'SignInScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i22.SignInScreen(); + return const _i30.SignInScreen(); }, ); } /// generated route for -/// [_i23.SuggestedWaterDailyGoalScreen] -class SuggestedWaterDailyGoalScreen extends _i26.PageRouteInfo { - const SuggestedWaterDailyGoalScreen({List<_i26.PageRouteInfo>? children}) +/// [_i31.SuggestedWaterDailyGoalScreen] +class SuggestedWaterDailyGoalScreen extends _i34.PageRouteInfo { + const SuggestedWaterDailyGoalScreen({List<_i34.PageRouteInfo>? children}) : super(SuggestedWaterDailyGoalScreen.name, initialChildren: children); static const String name = 'SuggestedWaterDailyGoalScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i23.SuggestedWaterDailyGoalScreen(); + return const _i31.SuggestedWaterDailyGoalScreen(); }, ); } /// generated route for -/// [_i24.WaterEmptyScreen] -class WaterEmptyScreen extends _i26.PageRouteInfo { - const WaterEmptyScreen({List<_i26.PageRouteInfo>? children}) +/// [_i32.WaterEmptyScreen] +class WaterEmptyScreen extends _i34.PageRouteInfo { + const WaterEmptyScreen({List<_i34.PageRouteInfo>? children}) : super(WaterEmptyScreen.name, initialChildren: children); static const String name = 'WaterEmptyScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i24.WaterEmptyScreen(); + return const _i32.WaterEmptyScreen(); }, ); } /// generated route for -/// [_i25.WaterScreen] -class WaterScreen extends _i26.PageRouteInfo { - const WaterScreen({List<_i26.PageRouteInfo>? children}) +/// [_i33.WaterScreen] +class WaterScreen extends _i34.PageRouteInfo { + const WaterScreen({List<_i34.PageRouteInfo>? children}) : super(WaterScreen.name, initialChildren: children); static const String name = 'WaterScreen'; - static _i26.PageInfo page = _i26.PageInfo( + static _i34.PageInfo page = _i34.PageInfo( name, builder: (data) { - return const _i25.WaterScreen(); + return const _i33.WaterScreen(); }, ); } From 19eb2c9a5de87df091c739fe5fb423241c97776c Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:06 +0100 Subject: [PATCH 13/33] regenerate objectbox and provider code - adds the HlaTestRequest entity to the objectbox model - adds UserProfile.dbRole property - incidental build_runner refresh of water_log_notifier.g.dart --- .../water/providers/water_log_notifier.g.dart | 2 +- lib/objectbox-model.json | 107 ++++- lib/objectbox.g.dart | 380 +++++++++++++++++- 3 files changed, 480 insertions(+), 9 deletions(-) diff --git a/lib/features/water/providers/water_log_notifier.g.dart b/lib/features/water/providers/water_log_notifier.g.dart index f20665a..d40d556 100644 --- a/lib/features/water/providers/water_log_notifier.g.dart +++ b/lib/features/water/providers/water_log_notifier.g.dart @@ -6,7 +6,7 @@ part of 'water_log_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$waterLogNotifierHash() => r'0c08cab052887a0c1492e64de331fb091de035b9'; +String _$waterLogNotifierHash() => r'cfad132966331323adc2ef20c8ac3578793c542c'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/objectbox-model.json b/lib/objectbox-model.json index 19660c4..e592db8 100644 --- a/lib/objectbox-model.json +++ b/lib/objectbox-model.json @@ -5,7 +5,7 @@ "entities": [ { "id": "2:7166028067144916976", - "lastPropertyId": "22:7473741569339705467", + "lastPropertyId": "23:3446162015300538428", "name": "UserProfile", "properties": [ { @@ -120,6 +120,11 @@ "id": "22:7473741569339705467", "name": "createdAt", "type": 10 + }, + { + "id": "23:3446162015300538428", + "name": "dbRole", + "type": 9 } ], "relations": [] @@ -527,10 +532,106 @@ } ], "relations": [] + }, + { + "id": "15:2928551838704968054", + "lastPropertyId": "17:8100974252932526204", + "name": "HlaTestRequest", + "properties": [ + { + "id": "1:3895586918529119094", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:299854502190908852", + "name": "uid", + "type": 9, + "flags": 34848, + "indexId": "12:5330856156796068253" + }, + { + "id": "3:6767700118325617687", + "name": "userId", + "type": 9 + }, + { + "id": "4:7970246581718522105", + "name": "patientName", + "type": 9 + }, + { + "id": "5:6102784803988311171", + "name": "appointmentSlotId", + "type": 9 + }, + { + "id": "6:1011341504457499727", + "name": "appointmentDate", + "type": 10 + }, + { + "id": "7:8538426739594004834", + "name": "collectionLocation", + "type": 9 + }, + { + "id": "8:2317583891011123815", + "name": "resultFileUrl", + "type": 9 + }, + { + "id": "9:4645561225361471759", + "name": "resultFileName", + "type": 9 + }, + { + "id": "10:1634046434977878014", + "name": "resultSummary", + "type": 9 + }, + { + "id": "11:3565336256070983570", + "name": "adminNotes", + "type": 9 + }, + { + "id": "12:5108099464489980828", + "name": "isCancelled", + "type": 1 + }, + { + "id": "13:7639696737927559332", + "name": "createdAt", + "type": 10 + }, + { + "id": "14:6814588809397735326", + "name": "updatedAt", + "type": 10 + }, + { + "id": "15:699322799870719491", + "name": "dbStatus", + "type": 9 + }, + { + "id": "16:6739967865939100593", + "name": "dbSubjects", + "type": 9 + }, + { + "id": "17:8100974252932526204", + "name": "dbStatusHistory", + "type": 9 + } + ], + "relations": [] } ], - "lastEntityId": "14:4275058572695879075", - "lastIndexId": "11:6292842501519135102", + "lastEntityId": "15:2928551838704968054", + "lastIndexId": "12:5330856156796068253", "lastRelationId": "3:7294710979211216290", "lastSequenceId": "0:0", "modelVersion": 5, diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart index f349fda..4f9edb9 100644 --- a/lib/objectbox.g.dart +++ b/lib/objectbox.g.dart @@ -16,6 +16,7 @@ import 'package:objectbox_flutter_libs/objectbox_flutter_libs.dart'; import 'features/auth/models/user/app_user.dart'; import 'features/auth/models/user/user_profile.dart'; +import 'features/hla/models/hla_test_request.dart'; import 'features/meds/models/med_activity_record.dart'; import 'features/meds/models/medication.dart'; import 'features/meds/models/scheduled_doses.dart'; @@ -28,7 +29,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(2, 7166028067144916976), name: 'UserProfile', - lastPropertyId: const obx_int.IdUid(22, 7473741569339705467), + lastPropertyId: const obx_int.IdUid(23, 3446162015300538428), flags: 0, properties: [ obx_int.ModelProperty( @@ -164,6 +165,12 @@ final _entities = [ type: 10, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(23, 3446162015300538428), + name: 'dbRole', + type: 9, + flags: 0, + ), ], relations: [], backlinks: [], @@ -641,6 +648,119 @@ final _entities = [ relations: [], backlinks: [], ), + obx_int.ModelEntity( + id: const obx_int.IdUid(15, 2928551838704968054), + name: 'HlaTestRequest', + lastPropertyId: const obx_int.IdUid(17, 8100974252932526204), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 3895586918529119094), + name: 'id', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 299854502190908852), + name: 'uid', + type: 9, + flags: 34848, + indexId: const obx_int.IdUid(12, 5330856156796068253), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 6767700118325617687), + name: 'userId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 7970246581718522105), + name: 'patientName', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 6102784803988311171), + name: 'appointmentSlotId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 1011341504457499727), + name: 'appointmentDate', + type: 10, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 8538426739594004834), + name: 'collectionLocation', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 2317583891011123815), + name: 'resultFileUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 4645561225361471759), + name: 'resultFileName', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 1634046434977878014), + name: 'resultSummary', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 3565336256070983570), + name: 'adminNotes', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 5108099464489980828), + name: 'isCancelled', + type: 1, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(13, 7639696737927559332), + name: 'createdAt', + type: 10, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(14, 6814588809397735326), + name: 'updatedAt', + type: 10, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(15, 699322799870719491), + name: 'dbStatus', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(16, 6739967865939100593), + name: 'dbSubjects', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(17, 8100974252932526204), + name: 'dbStatusHistory', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), ]; /// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter @@ -681,8 +801,8 @@ Future openStore({ obx_int.ModelDefinition getObjectBoxModel() { final model = obx_int.ModelInfo( entities: _entities, - lastEntityId: const obx_int.IdUid(14, 4275058572695879075), - lastIndexId: const obx_int.IdUid(11, 6292842501519135102), + lastEntityId: const obx_int.IdUid(15, 2928551838704968054), + lastIndexId: const obx_int.IdUid(12, 5330856156796068253), lastRelationId: const obx_int.IdUid(3, 7294710979211216290), lastSequenceId: const obx_int.IdUid(0, 0), retiredEntityUids: const [ @@ -833,7 +953,10 @@ obx_int.ModelDefinition getObjectBoxModel() { final dbGenotypeOffset = object.dbGenotype == null ? null : fbb.writeString(object.dbGenotype!); - fbb.startTable(23); + final dbRoleOffset = object.dbRole == null + ? null + : fbb.writeString(object.dbRole!); + fbb.startTable(24); fbb.addInt64(0, object.id); fbb.addOffset(1, uidOffset); fbb.addOffset(2, nameOffset); @@ -856,6 +979,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addBool(19, object.isSynced); fbb.addInt64(20, object.updatedAt?.millisecondsSinceEpoch); fbb.addInt64(21, object.createdAt?.millisecondsSinceEpoch); + fbb.addOffset(22, dbRoleOffset); fbb.finish(fbb.endTable()); return object.id; }, @@ -981,7 +1105,10 @@ obx_int.ModelDefinition getObjectBoxModel() { ).vTableGetNullable(buffer, rootOffset, 36) ..dbGenotype = const fb.StringReader( asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 38); + ).vTableGetNullable(buffer, rootOffset, 38) + ..dbRole = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 48); return object; }, @@ -1627,6 +1754,156 @@ obx_int.ModelDefinition getObjectBoxModel() { return object; }, ), + HlaTestRequest: obx_int.EntityDefinition( + model: _entities[7], + toOneRelations: (HlaTestRequest object) => [], + toManyRelations: (HlaTestRequest object) => {}, + getId: (HlaTestRequest object) => object.id, + setId: (HlaTestRequest object, int id) { + object.id = id; + }, + objectToFB: (HlaTestRequest object, fb.Builder fbb) { + final uidOffset = fbb.writeString(object.uid); + final userIdOffset = fbb.writeString(object.userId); + final patientNameOffset = fbb.writeString(object.patientName); + final appointmentSlotIdOffset = object.appointmentSlotId == null + ? null + : fbb.writeString(object.appointmentSlotId!); + final collectionLocationOffset = object.collectionLocation == null + ? null + : fbb.writeString(object.collectionLocation!); + final resultFileUrlOffset = object.resultFileUrl == null + ? null + : fbb.writeString(object.resultFileUrl!); + final resultFileNameOffset = object.resultFileName == null + ? null + : fbb.writeString(object.resultFileName!); + final resultSummaryOffset = object.resultSummary == null + ? null + : fbb.writeString(object.resultSummary!); + final adminNotesOffset = object.adminNotes == null + ? null + : fbb.writeString(object.adminNotes!); + final dbStatusOffset = fbb.writeString(object.dbStatus); + final dbSubjectsOffset = fbb.writeString(object.dbSubjects); + final dbStatusHistoryOffset = fbb.writeString(object.dbStatusHistory); + fbb.startTable(18); + fbb.addInt64(0, object.id); + fbb.addOffset(1, uidOffset); + fbb.addOffset(2, userIdOffset); + fbb.addOffset(3, patientNameOffset); + fbb.addOffset(4, appointmentSlotIdOffset); + fbb.addInt64(5, object.appointmentDate?.millisecondsSinceEpoch); + fbb.addOffset(6, collectionLocationOffset); + fbb.addOffset(7, resultFileUrlOffset); + fbb.addOffset(8, resultFileNameOffset); + fbb.addOffset(9, resultSummaryOffset); + fbb.addOffset(10, adminNotesOffset); + fbb.addBool(11, object.isCancelled); + fbb.addInt64(12, object.createdAt?.millisecondsSinceEpoch); + fbb.addInt64(13, object.updatedAt?.millisecondsSinceEpoch); + fbb.addOffset(14, dbStatusOffset); + fbb.addOffset(15, dbSubjectsOffset); + fbb.addOffset(16, dbStatusHistoryOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final appointmentDateValue = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 14, + ); + final createdAtValue = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 28, + ); + final updatedAtValue = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 30, + ); + final idParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 4, + 0, + ); + final uidParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final userIdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final patientNameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 10, ''); + final appointmentSlotIdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 12); + final appointmentDateParam = appointmentDateValue == null + ? null + : DateTime.fromMillisecondsSinceEpoch(appointmentDateValue); + final collectionLocationParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 16); + final resultFileUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 18); + final resultFileNameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 20); + final resultSummaryParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 22); + final adminNotesParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 24); + final isCancelledParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 26, + false, + ); + final createdAtParam = createdAtValue == null + ? null + : DateTime.fromMillisecondsSinceEpoch(createdAtValue); + final updatedAtParam = updatedAtValue == null + ? null + : DateTime.fromMillisecondsSinceEpoch(updatedAtValue); + final object = + HlaTestRequest( + id: idParam, + uid: uidParam, + userId: userIdParam, + patientName: patientNameParam, + appointmentSlotId: appointmentSlotIdParam, + appointmentDate: appointmentDateParam, + collectionLocation: collectionLocationParam, + resultFileUrl: resultFileUrlParam, + resultFileName: resultFileNameParam, + resultSummary: resultSummaryParam, + adminNotes: adminNotesParam, + isCancelled: isCancelledParam, + createdAt: createdAtParam, + updatedAt: updatedAtParam, + ) + ..dbStatus = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 32, '') + ..dbSubjects = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 34, '') + ..dbStatusHistory = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 36, ''); + + return object; + }, + ), }; return obx_int.ModelDefinition(model, bindings); @@ -1743,6 +2020,11 @@ class UserProfile_ { static final createdAt = obx.QueryDateProperty( _entities[0].properties[21], ); + + /// See [UserProfile.dbRole]. + static final dbRole = obx.QueryStringProperty( + _entities[0].properties[22], + ); } /// [WaterLog] entity fields to define ObjectBox queries. @@ -2103,3 +2385,91 @@ class ScheduledDose_ { _entities[6].properties[14], ); } + +/// [HlaTestRequest] entity fields to define ObjectBox queries. +class HlaTestRequest_ { + /// See [HlaTestRequest.id]. + static final id = obx.QueryIntegerProperty( + _entities[7].properties[0], + ); + + /// See [HlaTestRequest.uid]. + static final uid = obx.QueryStringProperty( + _entities[7].properties[1], + ); + + /// See [HlaTestRequest.userId]. + static final userId = obx.QueryStringProperty( + _entities[7].properties[2], + ); + + /// See [HlaTestRequest.patientName]. + static final patientName = obx.QueryStringProperty( + _entities[7].properties[3], + ); + + /// See [HlaTestRequest.appointmentSlotId]. + static final appointmentSlotId = obx.QueryStringProperty( + _entities[7].properties[4], + ); + + /// See [HlaTestRequest.appointmentDate]. + static final appointmentDate = obx.QueryDateProperty( + _entities[7].properties[5], + ); + + /// See [HlaTestRequest.collectionLocation]. + static final collectionLocation = obx.QueryStringProperty( + _entities[7].properties[6], + ); + + /// See [HlaTestRequest.resultFileUrl]. + static final resultFileUrl = obx.QueryStringProperty( + _entities[7].properties[7], + ); + + /// See [HlaTestRequest.resultFileName]. + static final resultFileName = obx.QueryStringProperty( + _entities[7].properties[8], + ); + + /// See [HlaTestRequest.resultSummary]. + static final resultSummary = obx.QueryStringProperty( + _entities[7].properties[9], + ); + + /// See [HlaTestRequest.adminNotes]. + static final adminNotes = obx.QueryStringProperty( + _entities[7].properties[10], + ); + + /// See [HlaTestRequest.isCancelled]. + static final isCancelled = obx.QueryBooleanProperty( + _entities[7].properties[11], + ); + + /// See [HlaTestRequest.createdAt]. + static final createdAt = obx.QueryDateProperty( + _entities[7].properties[12], + ); + + /// See [HlaTestRequest.updatedAt]. + static final updatedAt = obx.QueryDateProperty( + _entities[7].properties[13], + ); + + /// See [HlaTestRequest.dbStatus]. + static final dbStatus = obx.QueryStringProperty( + _entities[7].properties[14], + ); + + /// See [HlaTestRequest.dbSubjects]. + static final dbSubjects = obx.QueryStringProperty( + _entities[7].properties[15], + ); + + /// See [HlaTestRequest.dbStatusHistory]. + static final dbStatusHistory = obx.QueryStringProperty( + _entities[7].properties[16], + ); +} From b8c54b09665c00d0ce1bc619e4139f9c5272455c Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:07 +0100 Subject: [PATCH 14/33] add hla entry card to home - tappable card on the dashboard linking to the hla intro screen --- lib/features/home/screens/home_screen.dart | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 2c264b8..2168e6c 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -5,10 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gap/gap.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; + import '../../../core/core.dart'; import '../../auth/auth.dart'; import '../../emergency/emergency.dart'; import '../../health_connect/health_connect.dart'; +import '../../hla/hla.dart'; import '../../profile/profile.dart'; import '../../water/water.dart'; import '../home.dart'; @@ -148,6 +151,12 @@ class _HomeScreenState extends ConsumerState { context.router.pushNamed(EmergencyScreen.path); }, ), + const Gap(kPadding16), + _HlaEntryCard( + onPressed: () { + context.router.pushNamed(HlaIntroScreen.path); + }, + ), const SizedBox( height: kPadding64 * 2, ) @@ -158,3 +167,66 @@ class _HomeScreenState extends ConsumerState { ); } } + +/// Home dashboard entry point into the HLA typing feature. +class _HlaEntryCard extends StatelessWidget { + const _HlaEntryCard({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + splashFactory: InkSparkle.splashFactory, + splashColor: theme.colorScheme.primary.withValues(alpha: .2), + borderRadius: BorderRadius.circular(kPadding24), + onTap: onPressed, + child: Ink( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(kPadding24), + color: theme.colorScheme.secondaryContainer, + ), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: theme.colorScheme.secondary.withValues(alpha: .2), + child: Icon( + FluentIcons.organization_24_regular, + color: theme.colorScheme.secondary, + ), + ), + const Gap(kPadding16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "HLA Typing", + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + const Gap(kPadding4), + Text( + "Start your transplant matching journey", + style: AppTextStyles.bodySmall.copyWith( + color: theme.colorScheme.onSecondaryContainer + .withValues(alpha: .8), + ), + ), + ], + ), + ), + Icon( + FluentIcons.chevron_right_24_regular, + color: theme.colorScheme.onSecondaryContainer, + ), + ], + ), + ), + ); + } +} From 3d2d8d88d23600da27a11aef44c179b7f890b287 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:07 +0100 Subject: [PATCH 15/33] add hla entry points to profile - patient "hla typing" tile for everyone - admin "hla admin" tile shown only when role == admin --- .../profile/screens/profile_screen.dart | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/lib/features/profile/screens/profile_screen.dart b/lib/features/profile/screens/profile_screen.dart index 637389a..181500a 100644 --- a/lib/features/profile/screens/profile_screen.dart +++ b/lib/features/profile/screens/profile_screen.dart @@ -12,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../components/components.dart'; import '../../../core/core.dart'; import '../../../gen/assets.gen.dart'; +import '../../hla/hla.dart'; import '../../water/water.dart'; import '../profile.dart'; @@ -98,6 +99,10 @@ class ProfileScreen extends ConsumerWidget { ), ], ), + const Gap(24), + _HlaProfileSection( + isAdmin: userProfile.role == UserRole.admin, + ), // const Gap(16), // Container( // padding: const EdgeInsets.all(16), @@ -372,3 +377,83 @@ class ProfileScreen extends ConsumerWidget { ); } } + +/// HLA typing entry points on the profile: a patient-facing tile for everyone +/// and a staff-only admin tile gated on [isAdmin] (the role read). The server +/// also enforces the admin boundary via Firestore rules. +class _HlaProfileSection extends StatelessWidget { + const _HlaProfileSection({required this.isAdmin}); + + final bool isAdmin; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("HLA Typing", style: theme.textTheme.titleMedium), + const Gap(kPadding8), + _HlaNavTile( + icon: FluentIcons.organization_24_regular, + label: "HLA typing & transplant matching", + onTap: () => context.router.pushNamed(HlaIntroScreen.path), + ), + if (isAdmin) ...[ + const Gap(kPadding8), + _HlaNavTile( + icon: FluentIcons.shield_task_24_regular, + label: "HLA admin", + onTap: () => context.router.pushNamed(HlaAdminListScreen.path), + ), + ], + ], + ); + } +} + +class _HlaNavTile extends StatelessWidget { + const _HlaNavTile({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(kPadding16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(kPadding16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(kPadding16), + ), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.primary), + const Gap(kPadding12), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w500), + ), + ), + const Icon( + FluentIcons.chevron_right_24_regular, + size: 18, + color: AppColours.neutral50, + ), + ], + ), + ), + ); + } +} From 6e9f80758f859c6dea36692007c993f719784176 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:30 +0100 Subject: [PATCH 16/33] add hla security rules and indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - firestore rules for hla_test_requests (owner create/cancel/edit-while-requested, admin full control) and hla_appointment_slots (admin-managed, ±1 bookedCount for booking transaction) - admin gate via console-managed hla_admins collection that clients cannot write, avoiding role self-escalation - storage rules locking hla_results files to the owning patient + admins - composite indexes for the request and slot queries; wire storage into firebase.json --- firebase.json | 3 ++ firestore.indexes.json | 27 +++++++++++++++- firestore.rules | 73 ++++++++++++++++++++++++++++++++++++++++++ storage.rules | 39 ++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 storage.rules diff --git a/firebase.json b/firebase.json index 26b7964..33bf3f2 100644 --- a/firebase.json +++ b/firebase.json @@ -3,6 +3,9 @@ "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, + "storage": { + "rules": "storage.rules" + }, "flutter": { "platforms": { "android": { diff --git a/firestore.indexes.json b/firestore.indexes.json index 415027e..e4d47e8 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,4 +1,29 @@ { - "indexes": [], + "indexes": [ + { + "collectionGroup": "hla_test_requests", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "hla_test_requests", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "hla_appointment_slots", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "isActive", "order": "ASCENDING" }, + { "fieldPath": "dateTime", "order": "ASCENDING" } + ] + } + ], "fieldOverrides": [] } diff --git a/firestore.rules b/firestore.rules index 4285e0c..c65d480 100644 --- a/firestore.rules +++ b/firestore.rules @@ -9,6 +9,11 @@ rules_version = '2'; // /meds/{uid} -> medication document (array field) // /logs/{uid}/water/{monthYear} -> water log buckets, e.g. "June_2026" // +// HLA typing feature: +// /hla_test_requests/{requestId} -> one request per document +// /hla_appointment_slots/{slotId} -> admin-defined bookable slots +// /hla_admins/{uid} -> presence == staff/admin (console-managed) +// // Baseline policy: deny everything, then allow a signed-in user to access ONLY // the documents whose {uid} segment matches their own auth UID. No user can read // or write another user's health data. @@ -20,6 +25,16 @@ service cloud.firestore { return request.auth != null && request.auth.uid == uid; } + // Admin status is authoritative here: a user is staff iff a document exists + // at /hla_admins/{their-uid}. This collection is NOT writable by clients + // (see its match below), so a patient cannot escalate themselves to admin. + // Provision an admin by adding /hla_admins/{uid} in the Firebase console. + // (Hardening fast-follow: migrate to custom claims to avoid this per-check read.) + function isAdmin() { + return request.auth != null && + exists(/databases/$(database)/documents/hla_admins/$(request.auth.uid)); + } + // User profile and everything nested under it (e.g. /preferences/water). match /users/{uid} { allow read, write: if isOwner(uid); @@ -43,6 +58,64 @@ service cloud.firestore { } } + // ── HLA typing ────────────────────────────────────────────────────────── + + // Admin registry — read-only to clients (so the app can check its own + // status); never client-writable. Managed in the Firebase console. + match /hla_admins/{uid} { + allow read: if isOwner(uid) || isAdmin(); + allow write: if false; + } + + // HLA test requests. Patients own their request and may create it, edit the + // panel/appointment while it is still `requested`, and cancel. All forward + // status progression and result fields are admin-only. + match /hla_test_requests/{requestId} { + allow read: if isAdmin() || + (request.auth != null && resource.data.userId == request.auth.uid); + + allow create: if request.auth != null && + request.resource.data.userId == request.auth.uid && + request.resource.data.status == 'requested'; + + allow update: if isAdmin() || ( + request.auth != null && + resource.data.userId == request.auth.uid && + // Cannot change ownership. + request.resource.data.userId == resource.data.userId && + // Cannot touch admin/result-only fields. + request.resource.data.resultFileUrl == resource.data.resultFileUrl && + request.resource.data.resultSummary == resource.data.resultSummary && + request.resource.data.adminNotes == resource.data.adminNotes && + // Only allowed transitions for a patient: cancel at any cancellable + // point, edit while still `requested`, or confirm a booking. + ( + request.resource.data.status == 'cancelled' || + resource.data.status == 'requested' + ) + ); + + allow delete: if isAdmin(); + } + + // Appointment slots. Readable by any signed-in user; only admins manage + // them, except a signed-in user may adjust `bookedCount` by ±1 as part of + // the booking / cancellation transaction. + match /hla_appointment_slots/{slotId} { + allow read: if request.auth != null; + allow create, delete: if isAdmin(); + allow update: if isAdmin() || ( + request.auth != null && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['bookedCount']) && + ( + (request.resource.data.bookedCount == resource.data.bookedCount + 1 && + resource.data.bookedCount < resource.data.capacity) || + (request.resource.data.bookedCount == resource.data.bookedCount - 1 && + resource.data.bookedCount > 0) + ) + ); + } + // Explicit default-deny for anything not matched above. match /{document=**} { allow read, write: if false; diff --git a/storage.rules b/storage.rules new file mode 100644 index 0000000..9af78a0 --- /dev/null +++ b/storage.rules @@ -0,0 +1,39 @@ +rules_version = '2'; + +// Circle — Firebase Storage security rules +// +// Only HLA result documents live in Storage today. They contain medical data, +// so access is locked to the owning patient (read) and admins (read + write). +// Everything else is denied by default. +service firebase.storage { + match /b/{bucket}/o { + + function isSignedIn() { + return request.auth != null; + } + + // Mirrors the Firestore admin registry (console-managed, not client-writable). + function isAdmin() { + return isSignedIn() && + firestore.exists(/databases/(default)/documents/hla_admins/$(request.auth.uid)); + } + + // The patient who owns the request the file belongs to. + function isRequestOwner(requestId) { + return isSignedIn() && + firestore.get(/databases/(default)/documents/hla_test_requests/$(requestId)).data.userId + == request.auth.uid; + } + + // HLA result documents: hla_results/{requestId}/{fileName} + match /hla_results/{requestId}/{fileName} { + allow read: if isAdmin() || isRequestOwner(requestId); + allow write: if isAdmin(); + } + + // Default deny. + match /{allPaths=**} { + allow read, write: if false; + } + } +} From 2c3edb04ab67d7ec965bd6b8b40c1e752d59e74a Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 20:52:30 +0100 Subject: [PATCH 17/33] add hla unit tests - status helper coverage: order, next, terminal, cancellable, results-ready - request toMap/fromMap round-trip incl subjects and status history - hasResults and self-subject behaviour --- test/features/hla/hla_models_test.dart | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 test/features/hla/hla_models_test.dart diff --git a/test/features/hla/hla_models_test.dart b/test/features/hla/hla_models_test.dart new file mode 100644 index 0000000..ab9685f --- /dev/null +++ b/test/features/hla/hla_models_test.dart @@ -0,0 +1,111 @@ +import 'package:circle/core/utils/enums.dart'; +import 'package:circle/features/hla/models/hla_sample_subject.dart'; +import 'package:circle/features/hla/models/hla_status_event.dart'; +import 'package:circle/features/hla/models/hla_test_request.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HlaTestStatusX', () { + test('order follows declaration order; cancelled is -1', () { + expect(HlaTestStatus.requested.order, 0); + expect( + HlaTestStatus.bookingConfirmed.order > + HlaTestStatus.requested.order, + isTrue, + ); + expect(HlaTestStatus.cancelled.order, -1); + }); + + test('next advances forward and stops at resultsSent', () { + expect(HlaTestStatus.requested.next, HlaTestStatus.bookingConfirmed); + expect(HlaTestStatus.resultsAvailable.next, HlaTestStatus.resultsSent); + expect(HlaTestStatus.resultsSent.next, isNull); + expect(HlaTestStatus.cancelled.next, isNull); + }); + + test('terminal + cancellable flags', () { + expect(HlaTestStatus.resultsSent.isTerminal, isTrue); + expect(HlaTestStatus.cancelled.isTerminal, isTrue); + expect(HlaTestStatus.requested.isTerminal, isFalse); + + expect(HlaTestStatus.requested.isPatientCancellable, isTrue); + expect(HlaTestStatus.bookingConfirmed.isPatientCancellable, isTrue); + expect(HlaTestStatus.collected.isPatientCancellable, isFalse); + }); + + test('isResultsReady only once results are available', () { + expect(HlaTestStatus.tested.isResultsReady, isFalse); + expect(HlaTestStatus.resultsAvailable.isResultsReady, isTrue); + expect(HlaTestStatus.resultsSent.isResultsReady, isTrue); + expect(HlaTestStatus.cancelled.isResultsReady, isFalse); + }); + }); + + group('HlaTestRequest serialization', () { + test('toMap/fromMap round-trips subjects and status history', () { + final DateTime now = DateTime.utc(2026, 6, 24, 10, 30); + final request = HlaTestRequest( + uid: 'req-1', + userId: 'user-1', + patientName: 'Jane Doe', + status: HlaTestStatus.bookingConfirmed, + subjects: [ + HlaSampleSubject.self(name: 'Jane'), + const HlaSampleSubject( + name: 'John', + relation: HlaSubjectRelation.sibling, + genotype: Genotype.as, + ), + ], + statusHistory: [ + HlaStatusEvent(status: HlaTestStatus.requested, timestamp: now), + HlaStatusEvent( + status: HlaTestStatus.bookingConfirmed, + timestamp: now.add(const Duration(hours: 1)), + note: 'Slot booked', + ), + ], + appointmentDate: now.add(const Duration(days: 2)), + collectionLocation: 'Clinic A', + createdAt: now, + updatedAt: now, + ); + + final restored = HlaTestRequest.fromMap(request.toMap()); + + expect(restored.uid, request.uid); + expect(restored.userId, request.userId); + expect(restored.patientName, request.patientName); + expect(restored.status, HlaTestStatus.bookingConfirmed); + expect(restored.subjects.length, 2); + expect(restored.subjects.first.isSelf, isTrue); + expect(restored.subjects[1].relation, HlaSubjectRelation.sibling); + expect(restored.subjects[1].genotype, Genotype.as); + expect(restored.statusHistory.length, 2); + expect(restored.statusHistory.last.note, 'Slot booked'); + expect(restored.collectionLocation, 'Clinic A'); + }); + + test('hasResults requires both a results status and a file url', () { + final base = HlaTestRequest( + uid: 'r', + userId: 'u', + patientName: 'P', + status: HlaTestStatus.resultsAvailable, + ); + expect(base.hasResults, isFalse); + expect( + base.copyWith(resultFileUrl: 'https://x/y.pdf').hasResults, + isTrue, + ); + }); + }); + + group('HlaSampleSubject', () { + test('self factory marks the primary subject', () { + final self = HlaSampleSubject.self(name: 'Me'); + expect(self.isSelf, isTrue); + expect(self.relation, HlaSubjectRelation.self); + }); + }); +} From 4cd11a4df7c06822793835187281c85261325916 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:09 +0100 Subject: [PATCH 18/33] scaffold hla admin web app - flutter web-only project under admin/ for a monorepo-style sibling app - pubspec with firebase, flutter_riverpod, file_picker, intl, url_launcher deps - web shell (index.html, manifest, icons) and default tooling config --- admin/.gitignore | 45 ++ admin/.metadata | 30 + admin/analysis_options.yaml | 28 + admin/pubspec.lock | 794 ++++++++++++++++++++++++++ admin/pubspec.yaml | 98 ++++ admin/web/favicon.png | Bin 0 -> 917 bytes admin/web/icons/Icon-192.png | Bin 0 -> 5292 bytes admin/web/icons/Icon-512.png | Bin 0 -> 8252 bytes admin/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes admin/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes admin/web/index.html | 46 ++ admin/web/manifest.json | 35 ++ 12 files changed, 1076 insertions(+) create mode 100644 admin/.gitignore create mode 100644 admin/.metadata create mode 100644 admin/analysis_options.yaml create mode 100644 admin/pubspec.lock create mode 100644 admin/pubspec.yaml create mode 100644 admin/web/favicon.png create mode 100644 admin/web/icons/Icon-192.png create mode 100644 admin/web/icons/Icon-512.png create mode 100644 admin/web/icons/Icon-maskable-192.png create mode 100644 admin/web/icons/Icon-maskable-512.png create mode 100644 admin/web/index.html create mode 100644 admin/web/manifest.json diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/admin/.metadata b/admin/.metadata new file mode 100644 index 0000000..f5def37 --- /dev/null +++ b/admin/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "e1fd963c6f6922bd32afde2e9698a363cd0406d2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + base_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + - platform: web + create_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + base_revision: e1fd963c6f6922bd32afde2e9698a363cd0406d2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/admin/analysis_options.yaml b/admin/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/admin/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/admin/pubspec.lock b/admin/pubspec.lock new file mode 100644 index 0000000..7875f46 --- /dev/null +++ b/admin/pubspec.lock @@ -0,0 +1,794 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" + url: "https://pub.dev" + source: hosted + version: "1.3.73" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: f0dba6cedc1e1c84bb811c32ce1343cde4f1aa796dc4b5d00344e16a5342aed0 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: "07a03c220bc4579418a9e2ef02914a4c86b2b0c21d68a832faa523096bc20a00" + url: "https://pub.dev" + source: hosted + version: "8.0.3" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: a75fec71ecf6327f9a657a4c6b4dad5099d77bf578d110e10b233237f6cad3c0 + url: "https://pub.dev" + source: hosted + version: "5.6.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "7996a49c4b4054573eac9124c1c412095ae1406ca367bd2373de1ad2eaba04b8" + url: "https://pub.dev" + source: hosted + version: "6.5.4" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "2975965782c8cc46fe5bafc653882617e6fbdceed4499c97345c980734c50d2e" + url: "https://pub.dev" + source: hosted + version: "9.0.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: b35db5997b32889885dc9b1420b7c81b704f1ec153c3bb2efd228733e1878119 + url: "https://pub.dev" + source: hosted + version: "6.2.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: d2625088d8f8836a7a74a7eb94a5372d70ad88382602ba2dcc02805c294d0d16 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" + url: "https://pub.dev" + source: hosted + version: "3.9.0" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: f732bbcd82d21cfbe49b6fe5f4c50334f5f3660a151f004f58da388f8e39e6d3 + url: "https://pub.dev" + source: hosted + version: "13.4.3" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: "2515a8d16be44fffb3782669543804a651e73f637bb668db77d76a5bd38c396f" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: "05813d665afa2c2bafaba62c97dc1b510886b1b4fcec49bf05934bd78795c6e7" + url: "https://pub.dev" + source: hosted + version: "3.11.9" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/admin/pubspec.yaml b/admin/pubspec.yaml new file mode 100644 index 0000000..0faed13 --- /dev/null +++ b/admin/pubspec.yaml @@ -0,0 +1,98 @@ +name: hla_admin +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# 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.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + firebase_core: ^4.11.0 + firebase_auth: ^6.5.4 + cloud_firestore: ^6.6.0 + firebase_storage: ^13.4.3 + flutter_riverpod: ^3.3.2 + equatable: ^2.0.8 + intl: ^0.20.2 + file_picker: ^11.0.2 + url_launcher: ^6.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/admin/web/favicon.png b/admin/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/admin/web/icons/Icon-192.png b/admin/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/admin/web/icons/Icon-512.png b/admin/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/admin/web/icons/Icon-maskable-192.png b/admin/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/admin/web/icons/Icon-maskable-512.png b/admin/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/admin/web/index.html b/admin/web/index.html new file mode 100644 index 0000000..02c6f45 --- /dev/null +++ b/admin/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + hla_admin + + + + + + + diff --git a/admin/web/manifest.json b/admin/web/manifest.json new file mode 100644 index 0000000..8e20cb7 --- /dev/null +++ b/admin/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "hla_admin", + "short_name": "hla_admin", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} From a390f162641ae7a3b2e4292842cd62a1e7fcbd52 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:09 +0100 Subject: [PATCH 19/33] add admin app entry point and firebase config - main.dart initializes firebase and mounts the app inside a ProviderScope - firebase_options.dart with web config; apiKey/appId are placeholders pending a registered web app (flutterfire configure) - shares the circle-60af7 project identifiers with the mobile app --- admin/lib/firebase_options.dart | 26 ++++++++++++++++++++++++++ admin/lib/main.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 admin/lib/firebase_options.dart create mode 100644 admin/lib/main.dart diff --git a/admin/lib/firebase_options.dart b/admin/lib/firebase_options.dart new file mode 100644 index 0000000..cdef9a9 --- /dev/null +++ b/admin/lib/firebase_options.dart @@ -0,0 +1,26 @@ +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; + +/// Firebase configuration for the HLA admin **web** app. +/// +/// ⚠️ The `apiKey` and `appId` below are PLACEHOLDERS. A Web app must be +/// registered on the `circle-60af7` Firebase project to obtain them. The +/// quickest way is to run, from this `admin/` directory: +/// +/// flutterfire configure --project=circle-60af7 --platforms=web +/// +/// which regenerates this file with the real web credentials. Alternatively, +/// register a Web app in the Firebase console (Project settings → Your apps → +/// Add app → Web) and paste its `apiKey` and `appId` here. +/// +/// The non-secret identifiers (projectId, messagingSenderId, storageBucket) +/// are shared with the mobile app and already filled in. +class DefaultFirebaseOptions { + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'REPLACE_WITH_WEB_API_KEY', + appId: 'REPLACE_WITH_WEB_APP_ID', + messagingSenderId: '497391679746', + projectId: 'circle-60af7', + authDomain: 'circle-60af7.firebaseapp.com', + storageBucket: 'circle-60af7.firebasestorage.app', + ); +} diff --git a/admin/lib/main.dart b/admin/lib/main.dart new file mode 100644 index 0000000..442eaab --- /dev/null +++ b/admin/lib/main.dart @@ -0,0 +1,27 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/theme.dart'; +import 'firebase_options.dart'; +import 'screens/app_root.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.web); + runApp(const ProviderScope(child: HlaAdminApp())); +} + +class HlaAdminApp extends StatelessWidget { + const HlaAdminApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Circle · HLA Admin', + debugShowCheckedModeBanner: false, + theme: buildAdminTheme(), + home: const AppRoot(), + ); + } +} From 63ec6d72bf1072f06e471b34aee85867923a73d7 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:09 +0100 Subject: [PATCH 20/33] add admin core utilities - enums (HlaTestStatus, HlaSubjectRelation) with helpers, names matching the mobile app - material 3 theme with the brand purple seed and shared spacing tokens - date formatting helpers and a lightweight AdminException --- admin/lib/core/enums.dart | 72 ++++++++++++++++++++++++++++++++++ admin/lib/core/exceptions.dart | 13 ++++++ admin/lib/core/format.dart | 7 ++++ admin/lib/core/theme.dart | 36 +++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 admin/lib/core/enums.dart create mode 100644 admin/lib/core/exceptions.dart create mode 100644 admin/lib/core/format.dart create mode 100644 admin/lib/core/theme.dart diff --git a/admin/lib/core/enums.dart b/admin/lib/core/enums.dart new file mode 100644 index 0000000..e0f4d6b --- /dev/null +++ b/admin/lib/core/enums.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +/// The stages of an HLA typing request's journey. Mirrors the mobile app's +/// `HlaTestStatus` exactly — the enum **names** are the values persisted in +/// Firestore, so they must stay in sync across both apps. +/// +/// Declaration order is the canonical forward order; [cancelled] sits outside +/// the sequence. +enum HlaTestStatus { + requested('Sample requested', Icons.note_add_outlined), + bookingConfirmed('Appointment booked', Icons.event_available_outlined), + collected('Sample collected', Icons.science_outlined), + sentForTesting('Sample sent for testing', Icons.inventory_2_outlined), + inTransit('Sample in transit', Icons.local_shipping_outlined), + tested('Sample tested', Icons.bolt_outlined), + resultsProcessing('Results processing', Icons.hourglass_top_outlined), + resultsAvailable('Results available', Icons.task_outlined), + resultsSent('Results sent', Icons.check_circle_outline), + cancelled('Cancelled', Icons.cancel_outlined); + + const HlaTestStatus(this.label, this.icon); + + final String label; + final IconData icon; +} + +extension HlaTestStatusX on HlaTestStatus { + /// Position in the forward sequence; -1 for [cancelled]. + int get order => + this == HlaTestStatus.cancelled ? -1 : HlaTestStatus.values.indexOf(this); + + bool get isCancelledState => this == HlaTestStatus.cancelled; + + bool get isTerminal => + this == HlaTestStatus.cancelled || this == HlaTestStatus.resultsSent; + + /// Next status in the forward sequence, or null at/after the end. + HlaTestStatus? get next { + if (isTerminal) return null; + final int nextIndex = order + 1; + // resultsSent is the last forward value; cancelled is excluded. + if (nextIndex >= HlaTestStatus.values.length - 1) { + return HlaTestStatus.resultsSent; + } + return HlaTestStatus.values[nextIndex]; + } + + bool get isResultsReady => + !isCancelledState && order >= HlaTestStatus.resultsAvailable.order; +} + +/// Relationship of a sample subject to the requesting patient. Names match the +/// mobile app's `HlaSubjectRelation`. +enum HlaSubjectRelation { + self('Self'), + sibling('Sibling'), + parent('Parent'), + child('Child'), + relative('Relative'), + potentialDonor('Potential donor'), + other('Other'); + + const HlaSubjectRelation(this.label); + final String label; + + static HlaSubjectRelation fromName(String? name) { + return HlaSubjectRelation.values.firstWhere( + (r) => r.name == name, + orElse: () => HlaSubjectRelation.other, + ); + } +} diff --git a/admin/lib/core/exceptions.dart b/admin/lib/core/exceptions.dart new file mode 100644 index 0000000..4f09cd8 --- /dev/null +++ b/admin/lib/core/exceptions.dart @@ -0,0 +1,13 @@ +/// A simple, user-facing error surfaced by services/repositories and rendered +/// by the UI (via `AsyncValue.error`). Keeps the web app free of the mobile +/// app's fpdart `Either`/`Failure` machinery — Riverpod's `AsyncValue.guard` +/// handles the functional error flow here. +class AdminException implements Exception { + AdminException(this.message, {this.cause}); + + final String message; + final Object? cause; + + @override + String toString() => 'AdminException: $message'; +} diff --git a/admin/lib/core/format.dart b/admin/lib/core/format.dart new file mode 100644 index 0000000..061a8ee --- /dev/null +++ b/admin/lib/core/format.dart @@ -0,0 +1,7 @@ +import 'package:intl/intl.dart'; + +/// Formats a (UTC) timestamp in the viewer's local time, e.g. "Jun 24, 2026 · 3:30 PM". +String formatDateTime(DateTime dt) => + DateFormat('MMM d, y · h:mm a').format(dt.toLocal()); + +String formatDate(DateTime dt) => DateFormat('MMM d, y').format(dt.toLocal()); diff --git a/admin/lib/core/theme.dart b/admin/lib/core/theme.dart new file mode 100644 index 0000000..c101d94 --- /dev/null +++ b/admin/lib/core/theme.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Brand purple seed, matching the mobile app's `AppColours.purpleSeed`. +const Color kBrandSeed = Color(0xFF7B4FFF); + +ThemeData buildAdminTheme() { + final ColorScheme scheme = ColorScheme.fromSeed( + seedColor: kBrandSeed, + brightness: Brightness.light, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + visualDensity: VisualDensity.comfortable, + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: scheme.outlineVariant), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + ), + ); +} + +/// Shared spacing tokens. +const double kGap4 = 4; +const double kGap8 = 8; +const double kGap12 = 12; +const double kGap16 = 16; +const double kGap24 = 24; +const double kGap32 = 32; From 5313e23488c183813563504403165d5179eb5dd6 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:10 +0100 Subject: [PATCH 21/33] add web-adapted hla models - pure-dart HlaTestRequest, HlaSampleSubject, HlaStatusEvent, HlaAppointmentSlot - no objectbox; serialization matches the mobile app so the same firestore docs round-trip - genotype kept as a raw string since the admin only displays it --- admin/lib/models/hla_appointment_slot.dart | 67 +++++++++ admin/lib/models/hla_sample_subject.dart | 45 ++++++ admin/lib/models/hla_status_event.dart | 47 +++++++ admin/lib/models/hla_test_request.dart | 155 +++++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 admin/lib/models/hla_appointment_slot.dart create mode 100644 admin/lib/models/hla_sample_subject.dart create mode 100644 admin/lib/models/hla_status_event.dart create mode 100644 admin/lib/models/hla_test_request.dart diff --git a/admin/lib/models/hla_appointment_slot.dart b/admin/lib/models/hla_appointment_slot.dart new file mode 100644 index 0000000..73b5baf --- /dev/null +++ b/admin/lib/models/hla_appointment_slot.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; + +/// A bookable collection slot. Mirrors the mobile `HlaAppointmentSlot`. +class HlaAppointmentSlot extends Equatable { + const HlaAppointmentSlot({ + required this.id, + required this.dateTime, + required this.location, + required this.capacity, + this.bookedCount = 0, + this.isActive = true, + }); + + final String id; + final DateTime dateTime; + final String location; + final int capacity; + final int bookedCount; + final bool isActive; + + int get remainingSeats => (capacity - bookedCount).clamp(0, capacity); + + bool get isFull => bookedCount >= capacity; + + HlaAppointmentSlot copyWith({ + String? id, + DateTime? dateTime, + String? location, + int? capacity, + int? bookedCount, + bool? isActive, + }) { + return HlaAppointmentSlot( + id: id ?? this.id, + dateTime: dateTime ?? this.dateTime, + location: location ?? this.location, + capacity: capacity ?? this.capacity, + bookedCount: bookedCount ?? this.bookedCount, + isActive: isActive ?? this.isActive, + ); + } + + Map toMap() => { + 'id': id, + 'dateTime': dateTime.toUtc().toIso8601String(), + 'location': location, + 'capacity': capacity, + 'bookedCount': bookedCount, + 'isActive': isActive, + }; + + factory HlaAppointmentSlot.fromMap(Map map) { + return HlaAppointmentSlot( + id: (map['id'] as String?) ?? '', + dateTime: + DateTime.tryParse(map['dateTime'] as String? ?? '') ?? DateTime.now(), + location: (map['location'] as String?) ?? '', + capacity: (map['capacity'] as num?)?.toInt() ?? 0, + bookedCount: (map['bookedCount'] as num?)?.toInt() ?? 0, + isActive: map['isActive'] as bool? ?? true, + ); + } + + @override + List get props => + [id, dateTime, location, capacity, bookedCount, isActive]; +} diff --git a/admin/lib/models/hla_sample_subject.dart b/admin/lib/models/hla_sample_subject.dart new file mode 100644 index 0000000..901935f --- /dev/null +++ b/admin/lib/models/hla_sample_subject.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; + +import '../core/enums.dart'; + +/// One person in the cross-match panel. Mirrors the mobile `HlaSampleSubject` +/// serialization. Genotype is kept as the raw string the mobile app stored +/// (the admin only displays it), avoiding a duplicated Genotype enum. +class HlaSampleSubject extends Equatable { + const HlaSampleSubject({ + required this.name, + required this.relation, + this.dob, + this.genotype, + this.phone, + }); + + final String name; + final HlaSubjectRelation relation; + final DateTime? dob; + final String? genotype; + final String? phone; + + bool get isSelf => relation == HlaSubjectRelation.self; + + Map toMap() => { + 'name': name, + 'relation': relation.name, + 'dob': dob?.toUtc().toIso8601String(), + 'genotype': genotype, + 'phone': phone, + }; + + factory HlaSampleSubject.fromMap(Map map) { + return HlaSampleSubject( + name: (map['name'] as String?) ?? '', + relation: HlaSubjectRelation.fromName(map['relation'] as String?), + dob: map['dob'] != null ? DateTime.tryParse(map['dob'] as String) : null, + genotype: map['genotype'] as String?, + phone: map['phone'] as String?, + ); + } + + @override + List get props => [name, relation, dob, genotype, phone]; +} diff --git a/admin/lib/models/hla_status_event.dart b/admin/lib/models/hla_status_event.dart new file mode 100644 index 0000000..99f387c --- /dev/null +++ b/admin/lib/models/hla_status_event.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +import '../core/enums.dart'; + +/// One entry in a request's status history. Mirrors the mobile +/// `HlaStatusEvent` serialization. +class HlaStatusEvent extends Equatable { + const HlaStatusEvent({ + required this.status, + required this.timestamp, + this.note, + }); + + final HlaTestStatus status; + final DateTime timestamp; + final String? note; + + factory HlaStatusEvent.now({required HlaTestStatus status, String? note}) { + return HlaStatusEvent( + status: status, + timestamp: DateTime.now().toUtc(), + note: note, + ); + } + + Map toMap() => { + 'status': status.name, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'note': note, + }; + + factory HlaStatusEvent.fromMap(Map map) { + return HlaStatusEvent( + status: HlaTestStatus.values.firstWhere( + (s) => s.name == map['status'], + orElse: () => HlaTestStatus.requested, + ), + timestamp: + DateTime.tryParse(map['timestamp'] as String? ?? '') ?? + DateTime.now().toUtc(), + note: map['note'] as String?, + ); + } + + @override + List get props => [status, timestamp, note]; +} diff --git a/admin/lib/models/hla_test_request.dart b/admin/lib/models/hla_test_request.dart new file mode 100644 index 0000000..dc53587 --- /dev/null +++ b/admin/lib/models/hla_test_request.dart @@ -0,0 +1,155 @@ +import 'package:equatable/equatable.dart'; + +import '../core/enums.dart'; +import 'hla_sample_subject.dart'; +import 'hla_status_event.dart'; + +/// A patient's HLA typing request, as stored in `hla_test_requests/{id}`. +/// Serialization mirrors the mobile `HlaTestRequest` so the same documents +/// round-trip between both apps. ObjectBox is intentionally absent here. +class HlaTestRequest extends Equatable { + const HlaTestRequest({ + required this.uid, + required this.userId, + required this.patientName, + this.status = HlaTestStatus.requested, + this.subjects = const [], + this.statusHistory = const [], + this.appointmentSlotId, + this.appointmentDate, + this.collectionLocation, + this.resultFileUrl, + this.resultFileName, + this.resultSummary, + this.adminNotes, + this.isCancelled = false, + this.createdAt, + this.updatedAt, + }); + + /// Firestore document id (the mobile app stores it under the `id` key). + final String uid; + final String userId; + final String patientName; + final HlaTestStatus status; + final List subjects; + final List statusHistory; + final String? appointmentSlotId; + final DateTime? appointmentDate; + final String? collectionLocation; + final String? resultFileUrl; + final String? resultFileName; + final String? resultSummary; + final String? adminNotes; + final bool isCancelled; + final DateTime? createdAt; + final DateTime? updatedAt; + + bool get hasResults => status.isResultsReady && resultFileUrl != null; + + HlaTestRequest copyWith({ + HlaTestStatus? status, + List? statusHistory, + String? resultFileUrl, + String? resultFileName, + String? resultSummary, + String? adminNotes, + bool? isCancelled, + DateTime? updatedAt, + }) { + return HlaTestRequest( + uid: uid, + userId: userId, + patientName: patientName, + status: status ?? this.status, + subjects: subjects, + statusHistory: statusHistory ?? this.statusHistory, + appointmentSlotId: appointmentSlotId, + appointmentDate: appointmentDate, + collectionLocation: collectionLocation, + resultFileUrl: resultFileUrl ?? this.resultFileUrl, + resultFileName: resultFileName ?? this.resultFileName, + resultSummary: resultSummary ?? this.resultSummary, + adminNotes: adminNotes ?? this.adminNotes, + isCancelled: isCancelled ?? this.isCancelled, + createdAt: createdAt, + updatedAt: updatedAt ?? DateTime.now().toUtc(), + ); + } + + Map toMap() => { + 'id': uid, + 'userId': userId, + 'patientName': patientName, + 'status': status.name, + 'subjects': subjects.map((s) => s.toMap()).toList(), + 'statusHistory': statusHistory.map((e) => e.toMap()).toList(), + 'appointmentSlotId': appointmentSlotId, + 'appointmentDate': appointmentDate?.toUtc().toIso8601String(), + 'collectionLocation': collectionLocation, + 'resultFileUrl': resultFileUrl, + 'resultFileName': resultFileName, + 'resultSummary': resultSummary, + 'adminNotes': adminNotes, + 'isCancelled': isCancelled, + 'createdAt': createdAt?.toUtc().toIso8601String(), + 'updatedAt': updatedAt?.toUtc().toIso8601String(), + }; + + factory HlaTestRequest.fromMap(Map map) { + List parseList(dynamic raw, T Function(Map) fromMap) { + if (raw is! List) return []; + return raw + .map((e) => fromMap(Map.from(e as Map))) + .toList(); + } + + return HlaTestRequest( + uid: (map['id'] as String?) ?? '', + userId: (map['userId'] as String?) ?? '', + patientName: (map['patientName'] as String?) ?? '', + status: HlaTestStatus.values.firstWhere( + (s) => s.name == map['status'], + orElse: () => HlaTestStatus.requested, + ), + subjects: parseList(map['subjects'], HlaSampleSubject.fromMap), + statusHistory: parseList(map['statusHistory'], HlaStatusEvent.fromMap), + appointmentSlotId: map['appointmentSlotId'] as String?, + appointmentDate: map['appointmentDate'] != null + ? DateTime.tryParse(map['appointmentDate'] as String) + : null, + collectionLocation: map['collectionLocation'] as String?, + resultFileUrl: map['resultFileUrl'] as String?, + resultFileName: map['resultFileName'] as String?, + resultSummary: map['resultSummary'] as String?, + adminNotes: map['adminNotes'] as String?, + isCancelled: map['isCancelled'] as bool? ?? false, + createdAt: map['createdAt'] != null + ? DateTime.tryParse(map['createdAt'] as String) + : null, + updatedAt: map['updatedAt'] != null + ? DateTime.tryParse(map['updatedAt'] as String) + : null, + ); + } + + @override + List get props => [ + uid, + userId, + patientName, + status, + subjects, + statusHistory, + appointmentSlotId, + appointmentDate, + collectionLocation, + resultFileUrl, + resultFileName, + resultSummary, + adminNotes, + isCancelled, + createdAt, + updatedAt, + ]; +} From bdbb9be9180a330bd799895523b54c74a9be1c49 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:23 +0100 Subject: [PATCH 22/33] add admin auth and firestore services - auth service: google sign-in via browser popup, no google_sign_in plugin needed on web - hla admin service: realtime request/slot streams, request update, storage upload via putData, slot create + transactional release - isAdmin check against the hla_admins collection --- admin/lib/services/auth_service.dart | 25 +++++ admin/lib/services/hla_admin_service.dart | 114 ++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 admin/lib/services/auth_service.dart create mode 100644 admin/lib/services/hla_admin_service.dart diff --git a/admin/lib/services/auth_service.dart b/admin/lib/services/auth_service.dart new file mode 100644 index 0000000..b55420d --- /dev/null +++ b/admin/lib/services/auth_service.dart @@ -0,0 +1,25 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +import '../core/exceptions.dart'; + +/// Thin wrapper over Firebase Auth for the web admin. Uses Google sign-in via +/// the browser popup flow (no `google_sign_in` plugin needed on web). +class AuthService { + final FirebaseAuth _auth = FirebaseAuth.instance; + + Stream authStateChanges() => _auth.authStateChanges(); + + User? get currentUser => _auth.currentUser; + + Future signInWithGoogle() async { + try { + final provider = GoogleAuthProvider() + ..setCustomParameters({'prompt': 'select_account'}); + return await _auth.signInWithPopup(provider); + } on FirebaseAuthException catch (e) { + throw AdminException(e.message ?? 'Sign-in failed', cause: e); + } + } + + Future signOut() => _auth.signOut(); +} diff --git a/admin/lib/services/hla_admin_service.dart b/admin/lib/services/hla_admin_service.dart new file mode 100644 index 0000000..b851316 --- /dev/null +++ b/admin/lib/services/hla_admin_service.dart @@ -0,0 +1,114 @@ +import 'dart:typed_data'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_storage/firebase_storage.dart'; + +import '../core/enums.dart'; +import '../core/exceptions.dart'; +import '../models/hla_appointment_slot.dart'; +import '../models/hla_test_request.dart'; + +/// Firestore + Storage I/O for the admin dashboard. Talks to the same +/// collections the mobile app uses (`hla_test_requests`, `hla_appointment_slots`, +/// `hla_admins`). No business logic — that lives in the repository. +class HlaAdminService { + HlaAdminService({FirebaseFirestore? firestore, FirebaseStorage? storage}) + : _firestore = firestore ?? FirebaseFirestore.instance, + _storage = storage ?? FirebaseStorage.instance; + + final FirebaseFirestore _firestore; + final FirebaseStorage _storage; + + CollectionReference> get _requests => + _firestore.collection('hla_test_requests'); + CollectionReference> get _slots => + _firestore.collection('hla_appointment_slots'); + + /// True when the signed-in user has an `hla_admins/{uid}` document — the + /// authoritative admin gate enforced by the security rules. + Future isAdmin(String uid) async { + final doc = await _firestore.collection('hla_admins').doc(uid).get(); + return doc.exists; + } + + // ── Requests ────────────────────────────────────────────────────────────── + + Stream> streamRequests({HlaTestStatus? status}) { + Query> query = + _requests.orderBy('createdAt', descending: true); + if (status != null) { + query = _requests + .where('status', isEqualTo: status.name) + .orderBy('createdAt', descending: true); + } + return query.snapshots().map( + (snap) => + snap.docs.map((d) => HlaTestRequest.fromMap(d.data())).toList(), + ); + } + + Future updateRequest(HlaTestRequest request) async { + try { + await _requests + .doc(request.uid) + .set(request.toMap(), SetOptions(merge: true)); + } on FirebaseException catch (e) { + throw AdminException(e.message ?? 'Failed to update request', cause: e); + } + } + + Future uploadResult({ + required String requestId, + required Uint8List bytes, + required String fileName, + String? contentType, + }) async { + try { + final Reference ref = + _storage.ref().child('hla_results/$requestId/$fileName'); + await ref.putData(bytes, SettableMetadata(contentType: contentType)); + return await ref.getDownloadURL(); + } on FirebaseException catch (e) { + throw AdminException(e.message ?? 'Failed to upload result', cause: e); + } + } + + // ── Slots ─────────────────────────────────────────────────────────────────── + + Stream> streamSlots() { + return _slots.orderBy('dateTime').snapshots().map( + (snap) => snap.docs + .map((d) => HlaAppointmentSlot.fromMap(d.data())) + .toList(), + ); + } + + /// Creates (auto-id) or updates a slot. + Future putSlot(HlaAppointmentSlot slot) async { + try { + final DocumentReference> ref = + slot.id.isEmpty ? _slots.doc() : _slots.doc(slot.id); + final HlaAppointmentSlot toSave = + slot.id.isEmpty ? slot.copyWith(id: ref.id) : slot; + await ref.set(toSave.toMap(), SetOptions(merge: true)); + } on FirebaseException catch (e) { + throw AdminException(e.message ?? 'Failed to save slot', cause: e); + } + } + + /// Frees a booked seat (e.g. when an admin cancels a booked request). + Future releaseSlot(String slotId) async { + try { + final ref = _slots.doc(slotId); + await _firestore.runTransaction((txn) async { + final snap = await txn.get(ref); + final data = snap.data(); + if (!snap.exists || data == null) return; + final int current = (data['bookedCount'] as num?)?.toInt() ?? 0; + txn.update(ref, {'bookedCount': current > 0 ? current - 1 : 0}); + }); + } on FirebaseException catch (e) { + throw AdminException(e.message ?? 'Failed to free the slot', cause: e); + } + } +} From c0224ea349e2918d191020ce9edd599bbbd53186 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:23 +0100 Subject: [PATCH 23/33] add admin repository - orchestrates business logic: append status events, set result fields, free a slot when a booked request is cancelled - delegates i/o to the service which throws AdminException --- .../repositories/hla_admin_repository.dart | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 admin/lib/repositories/hla_admin_repository.dart diff --git a/admin/lib/repositories/hla_admin_repository.dart b/admin/lib/repositories/hla_admin_repository.dart new file mode 100644 index 0000000..b2d747a --- /dev/null +++ b/admin/lib/repositories/hla_admin_repository.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import '../core/enums.dart'; +import '../models/hla_appointment_slot.dart'; +import '../models/hla_status_event.dart'; +import '../models/hla_test_request.dart'; +import '../services/hla_admin_service.dart'; + +/// Admin business logic: appends status-history events, sets result fields, +/// and frees a slot when a booked request is cancelled. Delegates I/O to +/// [HlaAdminService] (which throws [AdminException] on failure). +class HlaAdminRepository { + HlaAdminRepository(this._service); + + final HlaAdminService _service; + + Stream> watchRequests({HlaTestStatus? status}) => + _service.streamRequests(status: status); + + Stream> watchSlots() => _service.streamSlots(); + + Future isAdmin(String uid) => _service.isAdmin(uid); + + Future advanceStatus({ + required HlaTestRequest request, + required HlaTestStatus status, + String? note, + }) async { + final HlaTestRequest updated = request.copyWith( + status: status, + isCancelled: status == HlaTestStatus.cancelled ? true : null, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: status, note: note), + ], + ); + await _service.updateRequest(updated); + if (status == HlaTestStatus.cancelled && request.appointmentSlotId != null) { + await _service.releaseSlot(request.appointmentSlotId!); + } + } + + Future uploadResult({ + required HlaTestRequest request, + required Uint8List bytes, + required String fileName, + String? contentType, + String? summary, + }) async { + final String url = await _service.uploadResult( + requestId: request.uid, + bytes: bytes, + fileName: fileName, + contentType: contentType, + ); + final HlaTestRequest updated = request.copyWith( + status: HlaTestStatus.resultsAvailable, + resultFileUrl: url, + resultFileName: fileName, + resultSummary: summary, + statusHistory: [ + ...request.statusHistory, + HlaStatusEvent.now(status: HlaTestStatus.resultsAvailable), + ], + ); + await _service.updateRequest(updated); + } + + Future putSlot(HlaAppointmentSlot slot) => _service.putSlot(slot); +} From 3f9f878212774ab35897158caaccc8b779d2b661 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:23 +0100 Subject: [PATCH 24/33] add admin riverpod providers - no codegen: plain Notifier/AsyncNotifier providers - auth state + admin gate, status filter, realtime requests and slots streams - AdminActions notifier for advance-status / upload-result / put-slot using AsyncValue.guard --- admin/lib/providers/providers.dart | 117 +++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 admin/lib/providers/providers.dart diff --git a/admin/lib/providers/providers.dart b/admin/lib/providers/providers.dart new file mode 100644 index 0000000..b599042 --- /dev/null +++ b/admin/lib/providers/providers.dart @@ -0,0 +1,117 @@ +import 'dart:typed_data'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/enums.dart'; +import '../models/hla_appointment_slot.dart'; +import '../models/hla_test_request.dart'; +import '../repositories/hla_admin_repository.dart'; +import '../services/auth_service.dart'; +import '../services/hla_admin_service.dart'; + +// ── Wiring ──────────────────────────────────────────────────────────────────── + +final authServiceProvider = Provider((ref) => AuthService()); + +final hlaAdminServiceProvider = + Provider((ref) => HlaAdminService()); + +final hlaAdminRepositoryProvider = Provider( + (ref) => HlaAdminRepository(ref.watch(hlaAdminServiceProvider)), +); + +// ── Auth + admin gate ───────────────────────────────────────────────────────── + +final authStateProvider = StreamProvider( + (ref) => ref.watch(authServiceProvider).authStateChanges(), +); + +/// Whether the signed-in user is a provisioned admin (`hla_admins/{uid}`). +/// Resolves false when signed out. +final adminStatusProvider = FutureProvider((ref) async { + final User? user = ref.watch(authStateProvider).value; + if (user == null) return false; + return ref.watch(hlaAdminRepositoryProvider).isAdmin(user.uid); +}); + +// ── Requests ────────────────────────────────────────────────────────────────── + +class StatusFilter extends Notifier { + @override + HlaTestStatus? build() => null; + void set(HlaTestStatus? status) => state = status; +} + +final statusFilterProvider = + NotifierProvider(StatusFilter.new); + +/// Realtime queue, reactive to the status filter. +final requestsProvider = StreamProvider>((ref) { + final HlaTestStatus? filter = ref.watch(statusFilterProvider); + return ref.watch(hlaAdminRepositoryProvider).watchRequests(status: filter); +}); + +class SelectedRequestId extends Notifier { + @override + String? build() => null; + void select(String? id) => state = id; +} + +final selectedRequestIdProvider = + NotifierProvider(SelectedRequestId.new); + +// ── Slots ─────────────────────────────────────────────────────────────────── + +final slotsProvider = StreamProvider>( + (ref) => ref.watch(hlaAdminRepositoryProvider).watchSlots(), +); + +// ── Mutations ───────────────────────────────────────────────────────────────── + +/// Drives the loading/error state of admin write actions. Methods return +/// `true` on success so screens can close dialogs / show confirmations. +class AdminActions extends AsyncNotifier { + @override + void build() {} + + HlaAdminRepository get _repo => ref.read(hlaAdminRepositoryProvider); + + Future _run(Future Function() action) async { + state = const AsyncValue.loading(); + final result = await AsyncValue.guard(action); + state = result; + return !result.hasError; + } + + Future advanceStatus({ + required HlaTestRequest request, + required HlaTestStatus status, + String? note, + }) => + _run(() => _repo.advanceStatus( + request: request, + status: status, + note: note, + )); + + Future uploadResult({ + required HlaTestRequest request, + required Uint8List bytes, + required String fileName, + String? contentType, + String? summary, + }) => + _run(() => _repo.uploadResult( + request: request, + bytes: bytes, + fileName: fileName, + contentType: contentType, + summary: summary, + )); + + Future putSlot(HlaAppointmentSlot slot) => _run(() => _repo.putSlot(slot)); +} + +final adminActionsProvider = + AsyncNotifierProvider(AdminActions.new); From 67887b07a1d77bc40778d49b99fcb9d42b1c3851 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:24 +0100 Subject: [PATCH 25/33] add admin shared widgets - status chip and a compact vertical status timeline --- admin/lib/widgets/status_chip.dart | 46 +++++++++ admin/lib/widgets/status_timeline.dart | 130 +++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 admin/lib/widgets/status_chip.dart create mode 100644 admin/lib/widgets/status_timeline.dart diff --git a/admin/lib/widgets/status_chip.dart b/admin/lib/widgets/status_chip.dart new file mode 100644 index 0000000..b79f310 --- /dev/null +++ b/admin/lib/widgets/status_chip.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../core/enums.dart'; + +/// A small colored chip representing an [HlaTestStatus]. +class StatusChip extends StatelessWidget { + const StatusChip(this.status, {super.key}); + + final HlaTestStatus status; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + late final Color bg; + late final Color fg; + if (status.isCancelledState) { + bg = scheme.errorContainer; + fg = scheme.onErrorContainer; + } else if (status.isResultsReady) { + bg = const Color(0xFFD7F5E0); + fg = const Color(0xFF0B6B36); + } else { + bg = scheme.primaryContainer; + fg = scheme.onPrimaryContainer; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(status.icon, size: 14, color: fg), + const SizedBox(width: 6), + Text( + status.label, + style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} diff --git a/admin/lib/widgets/status_timeline.dart b/admin/lib/widgets/status_timeline.dart new file mode 100644 index 0000000..b71958c --- /dev/null +++ b/admin/lib/widgets/status_timeline.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import '../core/enums.dart'; +import '../core/format.dart'; +import '../models/hla_status_event.dart'; + +/// A compact vertical timeline of the request's journey. Completed stages are +/// filled and show their timestamp + staff note; upcoming stages are dimmed. +class StatusTimeline extends StatelessWidget { + const StatusTimeline({ + super.key, + required this.currentStatus, + required this.history, + }); + + final HlaTestStatus currentStatus; + final List history; + + static final List _stages = HlaTestStatus.values + .where((s) => s != HlaTestStatus.cancelled) + .toList(); + + HlaStatusEvent? _eventFor(HlaTestStatus status) { + for (final e in history) { + if (e.status == status) return e; + } + return null; + } + + @override + Widget build(BuildContext context) { + final bool cancelled = currentStatus.isCancelledState; + final List stages = + cancelled ? history.map((e) => e.status).toList() : _stages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < stages.length; i++) + _Row( + status: stages[i], + event: _eventFor(stages[i]), + completed: cancelled || stages[i].order <= currentStatus.order, + current: stages[i] == currentStatus, + isLast: i == stages.length - 1, + ), + ], + ); + } +} + +class _Row extends StatelessWidget { + const _Row({ + required this.status, + required this.event, + required this.completed, + required this.current, + required this.isLast, + }); + + final HlaTestStatus status; + final HlaStatusEvent? event; + final bool completed; + final bool current; + final bool isLast; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final Color active = status.isCancelledState ? scheme.error : scheme.primary; + final Color line = completed ? active : scheme.outlineVariant; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: completed ? active : Colors.transparent, + shape: BoxShape.circle, + border: Border.all(color: line, width: 2), + ), + child: Icon( + status.icon, + size: 14, + color: completed ? scheme.onPrimary : scheme.outline, + ), + ), + if (!isLast) Expanded(child: Container(width: 2, color: line)), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + status.label, + style: TextStyle( + fontWeight: current ? FontWeight.w700 : FontWeight.w500, + color: completed ? scheme.onSurface : scheme.outline, + ), + ), + if (event != null) ...[ + const SizedBox(height: 2), + Text( + formatDateTime(event!.timestamp), + style: TextStyle(fontSize: 12, color: scheme.outline), + ), + if (event!.note != null && event!.note!.isNotEmpty) + Text( + event!.note!, + style: TextStyle(fontSize: 12, color: scheme.onSurface), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} From eefe6041f77e12196899a0d405a9246791b21635 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 26/33] add admin auth-gate screens - app_root gates on auth state then admin status (signed out / not admin / admin) - google sign-in screen and an access-denied screen with sign out --- admin/lib/screens/app_root.dart | 50 ++++++++++++ admin/lib/screens/not_authorized_screen.dart | 64 +++++++++++++++ admin/lib/screens/sign_in_screen.dart | 82 ++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 admin/lib/screens/app_root.dart create mode 100644 admin/lib/screens/not_authorized_screen.dart create mode 100644 admin/lib/screens/sign_in_screen.dart diff --git a/admin/lib/screens/app_root.dart b/admin/lib/screens/app_root.dart new file mode 100644 index 0000000..1ed340a --- /dev/null +++ b/admin/lib/screens/app_root.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/providers.dart'; +import 'dashboard_screen.dart'; +import 'not_authorized_screen.dart'; +import 'sign_in_screen.dart'; + +/// Auth + authorization gate. Signed-out → sign-in; signed-in non-admin → +/// not-authorized; signed-in admin → dashboard. +class AppRoot extends ConsumerWidget { + const AppRoot({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authStateProvider); + + return auth.when( + loading: () => const _Loading(), + error: (e, _) => _ErrorScaffold(message: e.toString()), + data: (user) { + if (user == null) return const SignInScreen(); + final admin = ref.watch(adminStatusProvider); + return admin.when( + loading: () => const _Loading(), + error: (_, _) => const NotAuthorizedScreen( + message: "We couldn't verify your admin access.", + ), + data: (isAdmin) => + isAdmin ? const DashboardScreen() : const NotAuthorizedScreen(), + ); + }, + ); + } +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => + const Scaffold(body: Center(child: CircularProgressIndicator())); +} + +class _ErrorScaffold extends StatelessWidget { + const _ErrorScaffold({required this.message}); + final String message; + @override + Widget build(BuildContext context) => + Scaffold(body: Center(child: Text(message))); +} diff --git a/admin/lib/screens/not_authorized_screen.dart b/admin/lib/screens/not_authorized_screen.dart new file mode 100644 index 0000000..6c59fa1 --- /dev/null +++ b/admin/lib/screens/not_authorized_screen.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/theme.dart'; +import '../providers/providers.dart'; + +/// Shown when a signed-in user is not a provisioned admin (no `hla_admins` +/// document). Offers a way back out. +class NotAuthorizedScreen extends ConsumerWidget { + const NotAuthorizedScreen({ + super.key, + this.message = "This account doesn't have admin access.", + }); + + final String message; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final email = ref.watch(authStateProvider).value?.email ?? ''; + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + child: Padding( + padding: const EdgeInsets.all(kGap32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, + size: 40, color: theme.colorScheme.error), + const SizedBox(height: kGap16), + Text('Access denied', style: theme.textTheme.titleLarge), + const SizedBox(height: kGap8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + if (email.isNotEmpty) ...[ + const SizedBox(height: kGap4), + Text( + 'Signed in as $email', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline), + ), + ], + const SizedBox(height: kGap24), + OutlinedButton.icon( + onPressed: () => ref.read(authServiceProvider).signOut(), + icon: const Icon(Icons.logout), + label: const Text('Sign out'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/admin/lib/screens/sign_in_screen.dart b/admin/lib/screens/sign_in_screen.dart new file mode 100644 index 0000000..9d7e037 --- /dev/null +++ b/admin/lib/screens/sign_in_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/exceptions.dart'; +import '../core/theme.dart'; +import '../providers/providers.dart'; + +class SignInScreen extends ConsumerStatefulWidget { + const SignInScreen({super.key}); + + @override + ConsumerState createState() => _SignInScreenState(); +} + +class _SignInScreenState extends ConsumerState { + bool _busy = false; + + Future _signIn() async { + setState(() => _busy = true); + try { + await ref.read(authServiceProvider).signInWithGoogle(); + // The auth-state listener in AppRoot routes onward automatically. + } on AdminException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Card( + child: Padding( + padding: const EdgeInsets.all(kGap32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 28, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.biotech_outlined, + color: theme.colorScheme.onPrimaryContainer), + ), + const SizedBox(height: kGap16), + Text('Circle · HLA Admin', + style: theme.textTheme.titleLarge), + const SizedBox(height: kGap8), + Text( + 'Staff access only. Sign in with your authorized Google account.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.outline), + ), + const SizedBox(height: kGap24), + FilledButton.icon( + onPressed: _busy ? null : _signIn, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: const Text('Sign in with Google'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} From 64a55ba23007e2bc06d43a5576da7884d9bcf6da Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 27/33] add admin dashboard shell - navigation rail switching between requests and slots - top bar showing the signed-in admin email with a sign out action --- admin/lib/screens/dashboard_screen.dart | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 admin/lib/screens/dashboard_screen.dart diff --git a/admin/lib/screens/dashboard_screen.dart b/admin/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..0f43355 --- /dev/null +++ b/admin/lib/screens/dashboard_screen.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/theme.dart'; +import '../providers/providers.dart'; +import 'requests_view.dart'; +import 'slots_view.dart'; + +class DashboardScreen extends ConsumerStatefulWidget { + const DashboardScreen({super.key}); + + @override + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + int _index = 0; + + static const _titles = ['Requests', 'Appointment slots']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final email = ref.watch(authStateProvider).value?.email ?? ''; + + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + labelType: NavigationRailLabelType.all, + leading: Padding( + padding: const EdgeInsets.symmetric(vertical: kGap16), + child: Icon(Icons.biotech_outlined, + color: theme.colorScheme.primary), + ), + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.inbox_outlined), + selectedIcon: Icon(Icons.inbox), + label: Text('Requests'), + ), + NavigationRailDestination( + icon: Icon(Icons.event_outlined), + selectedIcon: Icon(Icons.event), + label: Text('Slots'), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: Column( + children: [ + _TopBar(title: _titles[_index], email: email), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _index, + children: const [RequestsView(), SlotsView()], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _TopBar extends ConsumerWidget { + const _TopBar({required this.title, required this.email}); + + final String title; + final String email; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kGap24, vertical: kGap16), + child: Row( + children: [ + Text(title, style: theme.textTheme.titleLarge), + const Spacer(), + Text(email, style: theme.textTheme.bodyMedium), + const SizedBox(width: kGap12), + IconButton( + tooltip: 'Sign out', + onPressed: () => ref.read(authServiceProvider).signOut(), + icon: const Icon(Icons.logout), + ), + ], + ), + ); + } +} From 0c65700799f8015d33ad65061be2418978427f51 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 28/33] add admin requests queue and detail - master/detail: filterable realtime list on the left, selected request on the right - detail view: advance status with note, view the sample panel, upload a result document + summary, journey timeline --- admin/lib/screens/request_detail_view.dart | 280 +++++++++++++++++++++ admin/lib/screens/requests_view.dart | 202 +++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 admin/lib/screens/request_detail_view.dart create mode 100644 admin/lib/screens/requests_view.dart diff --git a/admin/lib/screens/request_detail_view.dart b/admin/lib/screens/request_detail_view.dart new file mode 100644 index 0000000..6c84f10 --- /dev/null +++ b/admin/lib/screens/request_detail_view.dart @@ -0,0 +1,280 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../core/enums.dart'; +import '../core/format.dart'; +import '../core/theme.dart'; +import '../models/hla_test_request.dart'; +import '../providers/providers.dart'; +import '../widgets/status_chip.dart'; +import '../widgets/status_timeline.dart'; + +class RequestDetailView extends ConsumerStatefulWidget { + const RequestDetailView({super.key, required this.requestId}); + + final String requestId; + + @override + ConsumerState createState() => _RequestDetailViewState(); +} + +class _RequestDetailViewState extends ConsumerState { + final _noteController = TextEditingController(); + final _summaryController = TextEditingController(); + HlaTestStatus? _targetStatus; + + @override + void dispose() { + _noteController.dispose(); + _summaryController.dispose(); + super.dispose(); + } + + HlaTestRequest? _find(List items) { + for (final r in items) { + if (r.uid == widget.requestId) return r; + } + return null; + } + + bool get _busy => ref.watch(adminActionsProvider).isLoading; + + void _toast(bool ok, String okMsg) { + final messenger = ScaffoldMessenger.of(context); + if (ok) { + messenger.showSnackBar(SnackBar(content: Text(okMsg))); + } else { + final err = ref.read(adminActionsProvider).error; + messenger.showSnackBar( + SnackBar(content: Text(err is Object ? '$err' : 'Action failed')), + ); + } + } + + Future _advance(HlaTestRequest request) async { + final status = _targetStatus ?? request.status.next; + if (status == null) return; + final ok = await ref.read(adminActionsProvider.notifier).advanceStatus( + request: request, + status: status, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + if (!mounted) return; + if (ok) _noteController.clear(); + _toast(ok, 'Status updated to ${status.label}'); + } + + Future _upload(HlaTestRequest request) async { + final result = await FilePicker.pickFiles( + withData: true, + type: FileType.custom, + allowedExtensions: const ['pdf', 'jpg', 'jpeg', 'png'], + ); + final file = result?.files.single; + if (file == null || file.bytes == null) return; + final ok = await ref.read(adminActionsProvider.notifier).uploadResult( + request: request, + bytes: file.bytes!, + fileName: file.name, + contentType: _contentType(file.extension), + summary: _summaryController.text.trim().isEmpty + ? null + : _summaryController.text.trim(), + ); + if (!mounted) return; + _toast(ok, 'Result uploaded'); + } + + String? _contentType(String? ext) { + switch (ext?.toLowerCase()) { + case 'pdf': + return 'application/pdf'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return null; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final requests = ref.watch(requestsProvider); + final request = requests.maybeWhen( + data: _find, + orElse: () => null, + ); + + if (request == null) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView( + padding: const EdgeInsets.all(kGap24), + children: [ + Row( + children: [ + Expanded( + child: Text( + request.patientName.isEmpty + ? 'Unknown patient' + : request.patientName, + style: theme.textTheme.headlineSmall, + ), + ), + StatusChip(request.status), + ], + ), + const SizedBox(height: kGap8), + if (request.appointmentDate != null) + Text( + 'Appointment: ${formatDateTime(request.appointmentDate!)}' + '${request.collectionLocation != null ? ' · ${request.collectionLocation}' : ''}', + style: TextStyle(color: theme.colorScheme.outline), + ), + const SizedBox(height: kGap24), + + // ── Advance status ── + _SectionCard( + title: 'Advance status', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _targetStatus ?? request.status.next, + isExpanded: true, + decoration: const InputDecoration(labelText: 'New status'), + items: [ + for (final s in HlaTestStatus.values) + DropdownMenuItem(value: s, child: Text(s.label)), + ], + onChanged: (v) => setState(() => _targetStatus = v), + ), + const SizedBox(height: kGap12), + TextField( + controller: _noteController, + decoration: const InputDecoration(labelText: 'Note (optional)'), + ), + const SizedBox(height: kGap12), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _busy ? null : () => _advance(request), + child: const Text('Update status'), + ), + ), + ], + ), + ), + const SizedBox(height: kGap16), + + // ── Sample panel ── + _SectionCard( + title: 'Sample panel (${request.subjects.length})', + child: Column( + children: [ + for (final s in request.subjects) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline), + title: Text(s.name), + subtitle: Text( + s.isSelf ? 'Patient' : s.relation.label, + ), + trailing: s.genotype != null + ? Text(s.genotype!.toUpperCase()) + : null, + ), + ], + ), + ), + const SizedBox(height: kGap16), + + // ── Results ── + _SectionCard( + title: 'Results', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (request.resultFileUrl != null) + Padding( + padding: const EdgeInsets.only(bottom: kGap8), + child: Row( + children: [ + const Icon(Icons.description_outlined, size: 18), + const SizedBox(width: kGap8), + Expanded( + child: Text(request.resultFileName ?? 'Result document'), + ), + TextButton( + onPressed: () => + launchUrl(Uri.parse(request.resultFileUrl!)), + child: const Text('Open'), + ), + ], + ), + ), + TextField( + controller: _summaryController, + maxLines: 3, + decoration: + const InputDecoration(labelText: 'Result summary (optional)'), + ), + const SizedBox(height: kGap12), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _upload(request), + icon: const Icon(Icons.upload_file), + label: const Text('Upload result document'), + ), + ), + ], + ), + ), + const SizedBox(height: kGap16), + + // ── Journey ── + _SectionCard( + title: 'Journey', + child: StatusTimeline( + currentStatus: request.status, + history: request.statusHistory, + ), + ), + ], + ); + } +} + +class _SectionCard extends StatelessWidget { + const _SectionCard({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(kGap16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: kGap12), + child, + ], + ), + ), + ); + } +} diff --git a/admin/lib/screens/requests_view.dart b/admin/lib/screens/requests_view.dart new file mode 100644 index 0000000..69c1365 --- /dev/null +++ b/admin/lib/screens/requests_view.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/enums.dart'; +import '../core/format.dart'; +import '../core/theme.dart'; +import '../models/hla_test_request.dart'; +import '../providers/providers.dart'; +import '../widgets/status_chip.dart'; +import 'request_detail_view.dart'; + +/// Master/detail: filterable request list on the left, selected request on the +/// right. +class RequestsView extends ConsumerWidget { + const RequestsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedRequestIdProvider); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 380, + child: Column( + children: const [ + _FilterBar(), + Divider(height: 1), + Expanded(child: _RequestsList()), + ], + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: selectedId == null + ? const _EmptyDetail() + : RequestDetailView(key: ValueKey(selectedId), requestId: selectedId), + ), + ], + ); + } +} + +class _FilterBar extends ConsumerWidget { + const _FilterBar(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(statusFilterProvider); + return SizedBox( + height: 56, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kGap12), + children: [ + _chip(ref, label: 'All', selected: filter == null, value: null), + for (final s in HlaTestStatus.values) + _chip(ref, label: s.label, selected: filter == s, value: s), + ], + ), + ); + } + + Widget _chip(WidgetRef ref, + {required String label, + required bool selected, + required HlaTestStatus? value}) { + return Padding( + padding: const EdgeInsets.only(right: kGap8), + child: Center( + child: ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => + ref.read(statusFilterProvider.notifier).set(value), + ), + ), + ); + } +} + +class _RequestsList extends ConsumerWidget { + const _RequestsList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final requests = ref.watch(requestsProvider); + final selectedId = ref.watch(selectedRequestIdProvider); + + return requests.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ListError(message: _message(e)), + data: (items) { + if (items.isEmpty) { + return const Center(child: Text('No requests')); + } + return ListView.separated( + padding: const EdgeInsets.all(kGap12), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: kGap8), + itemBuilder: (context, i) { + final HlaTestRequest r = items[i]; + return _RequestTile( + request: r, + selected: r.uid == selectedId, + onTap: () => + ref.read(selectedRequestIdProvider.notifier).select(r.uid), + ); + }, + ); + }, + ); + } + + String _message(Object e) => + 'Could not load requests. Check that the Firestore rules are deployed ' + 'and your account is in hla_admins.\n\n$e'; +} + +class _RequestTile extends StatelessWidget { + const _RequestTile({ + required this.request, + required this.selected, + required this.onTap, + }); + + final HlaTestRequest request; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + color: selected ? scheme.primaryContainer : scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(kGap12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + request.patientName.isEmpty + ? 'Unknown patient' + : request.patientName, + style: const TextStyle(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: kGap8), + StatusChip(request.status), + const SizedBox(height: kGap8), + Text( + request.appointmentDate != null + ? formatDateTime(request.appointmentDate!) + : 'No appointment booked', + style: TextStyle(fontSize: 12, color: scheme.outline), + ), + ], + ), + ), + ), + ); + } +} + +class _ListError extends StatelessWidget { + const _ListError({required this.message}); + final String message; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(kGap24), + child: Center( + child: Text(message, + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ); + } +} + +class _EmptyDetail extends StatelessWidget { + const _EmptyDetail(); + @override + Widget build(BuildContext context) { + return Center( + child: Text( + 'Select a request to manage it', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ); + } +} From e582274c13533d158aaefca33419e571e23e2fe5 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 29/33] add admin slots management - realtime list of appointment slots with booked/capacity - add-slot dialog (date/time, location, capacity) and an active/hidden toggle --- admin/lib/screens/slots_view.dart | 218 ++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 admin/lib/screens/slots_view.dart diff --git a/admin/lib/screens/slots_view.dart b/admin/lib/screens/slots_view.dart new file mode 100644 index 0000000..741606f --- /dev/null +++ b/admin/lib/screens/slots_view.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/format.dart'; +import '../core/theme.dart'; +import '../models/hla_appointment_slot.dart'; +import '../providers/providers.dart'; + +class SlotsView extends ConsumerWidget { + const SlotsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final slots = ref.watch(slotsProvider); + final busy = ref.watch(adminActionsProvider).isLoading; + + return Padding( + padding: const EdgeInsets.all(kGap24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: busy ? null : () => _addSlot(context, ref), + icon: const Icon(Icons.add), + label: const Text('Add slot'), + ), + ), + const SizedBox(height: kGap16), + Expanded( + child: slots.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Could not load slots\n\n$e', + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + data: (items) { + if (items.isEmpty) { + return const Center(child: Text('No slots yet — add one')); + } + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: kGap8), + itemBuilder: (context, i) => + _SlotTile(slot: items[i], busy: busy), + ); + }, + ), + ), + ], + ), + ); + } + + Future _addSlot(BuildContext context, WidgetRef ref) async { + final slot = await showDialog( + context: context, + builder: (_) => const _AddSlotDialog(), + ); + if (slot == null) return; + final ok = await ref.read(adminActionsProvider.notifier).putSlot(slot); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(ok ? 'Slot added' : 'Could not add slot')), + ); + } +} + +class _SlotTile extends ConsumerWidget { + const _SlotTile({required this.slot, required this.busy}); + + final HlaAppointmentSlot slot; + final bool busy; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + return Card( + child: ListTile( + title: Text(formatDateTime(slot.dateTime)), + subtitle: Text( + '${slot.location} · ${slot.bookedCount}/${slot.capacity} booked', + style: TextStyle(color: scheme.outline), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(slot.isActive ? 'Active' : 'Hidden', + style: TextStyle(color: scheme.outline, fontSize: 12)), + Switch( + value: slot.isActive, + onChanged: busy + ? null + : (v) => ref + .read(adminActionsProvider.notifier) + .putSlot(slot.copyWith(isActive: v)), + ), + ], + ), + ), + ); + } +} + +class _AddSlotDialog extends StatefulWidget { + const _AddSlotDialog(); + + @override + State<_AddSlotDialog> createState() => _AddSlotDialogState(); +} + +class _AddSlotDialogState extends State<_AddSlotDialog> { + final _locationController = TextEditingController(); + final _capacityController = TextEditingController(text: '10'); + DateTime? _dateTime; + String? _error; + + @override + void dispose() { + _locationController.dispose(); + _capacityController.dispose(); + super.dispose(); + } + + Future _pickDateTime() async { + final now = DateTime.now(); + final date = await showDatePicker( + context: context, + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + initialDate: now, + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time == null) return; + setState(() { + _dateTime = + DateTime(date.year, date.month, date.day, time.hour, time.minute); + }); + } + + void _submit() { + final capacity = int.tryParse(_capacityController.text.trim()) ?? 0; + if (_dateTime == null) { + setState(() => _error = 'Pick a date and time'); + return; + } + if (_locationController.text.trim().isEmpty) { + setState(() => _error = 'Location is required'); + return; + } + if (capacity <= 0) { + setState(() => _error = 'Capacity must be at least 1'); + return; + } + Navigator.pop( + context, + HlaAppointmentSlot( + id: '', + dateTime: _dateTime!, + location: _locationController.text.trim(), + capacity: capacity, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add appointment slot'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.calendar_today_outlined), + label: Text( + _dateTime == null + ? 'Pick date & time' + : formatDateTime(_dateTime!), + ), + ), + const SizedBox(height: kGap12), + TextField( + controller: _locationController, + decoration: const InputDecoration(labelText: 'Location'), + ), + const SizedBox(height: kGap12), + TextField( + controller: _capacityController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Capacity'), + ), + if (_error != null) ...[ + const SizedBox(height: kGap8), + Text(_error!, + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Save slot')), + ], + ); + } +} From ebdd5ff55af57aba788d59b583301b5f3645f6b6 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 30/33] add admin unit tests - status helper coverage (next, isResultsReady) - HlaTestRequest fromMap/toMap round-trip against the firestore doc shape --- admin/test/widget_test.dart | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 admin/test/widget_test.dart diff --git a/admin/test/widget_test.dart b/admin/test/widget_test.dart new file mode 100644 index 0000000..ec5a656 --- /dev/null +++ b/admin/test/widget_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hla_admin/core/enums.dart'; +import 'package:hla_admin/models/hla_test_request.dart'; + +void main() { + group('HlaTestStatusX', () { + test('next advances and stops at resultsSent', () { + expect(HlaTestStatus.requested.next, HlaTestStatus.bookingConfirmed); + expect(HlaTestStatus.resultsAvailable.next, HlaTestStatus.resultsSent); + expect(HlaTestStatus.resultsSent.next, isNull); + expect(HlaTestStatus.cancelled.next, isNull); + }); + + test('isResultsReady only once results are available', () { + expect(HlaTestStatus.tested.isResultsReady, isFalse); + expect(HlaTestStatus.resultsAvailable.isResultsReady, isTrue); + expect(HlaTestStatus.cancelled.isResultsReady, isFalse); + }); + }); + + test('HlaTestRequest round-trips through the Firestore map shape', () { + final source = { + 'id': 'req-1', + 'userId': 'user-1', + 'patientName': 'Jane Doe', + 'status': 'bookingConfirmed', + 'subjects': [ + {'name': 'Jane', 'relation': 'self', 'genotype': 'ss'}, + {'name': 'John', 'relation': 'sibling'}, + ], + 'statusHistory': [ + {'status': 'requested', 'timestamp': '2026-06-24T10:00:00.000Z'}, + { + 'status': 'bookingConfirmed', + 'timestamp': '2026-06-24T11:00:00.000Z', + 'note': 'Booked', + }, + ], + 'collectionLocation': 'Clinic A', + 'isCancelled': false, + }; + + final request = HlaTestRequest.fromMap(source); + expect(request.uid, 'req-1'); + expect(request.status, HlaTestStatus.bookingConfirmed); + expect(request.subjects.length, 2); + expect(request.subjects.first.isSelf, isTrue); + expect(request.statusHistory.last.note, 'Booked'); + + // toMap preserves the key the mobile app reads the id from. + expect(request.toMap()['id'], 'req-1'); + expect(request.toMap()['status'], 'bookingConfirmed'); + }); +} From 7a8ecfcd72c86aeebcbd9ad9631b081a41880b02 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 24 Jun 2026 22:37:40 +0100 Subject: [PATCH 31/33] add admin setup readme - explains the standalone web app and how it shares firebase with the mobile app - setup steps: flutterfire configure web, enable google provider, deploy rules, provision hla_admins - run/build/test commands and architecture overview --- admin/README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 admin/README.md diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..9182a2a --- /dev/null +++ b/admin/README.md @@ -0,0 +1,92 @@ +# Circle · HLA Admin (Flutter Web) + +A standalone Flutter **web** dashboard for Circle staff to manage the HLA typing +feature. It is a sibling app in this monorepo (`admin/`) and talks to the **same +Firebase project** (`circle-60af7`) as the mobile app — the same Firestore +collections (`hla_test_requests`, `hla_appointment_slots`, `hla_admins`) and the +same Storage path (`hla_results/`). + +It is deliberately **not** coupled to the mobile app's code: the mobile HLA +models are ObjectBox entities and the app pulls in mobile-only plugins that don't +build for web. This app re-implements the small HLA data layer in pure Dart, with +serialization that matches the mobile app exactly so the same documents round-trip. + +## What staff can do + +- **Requests queue** — see every request in real time, filter by status, open one. +- **Advance status** — move a request through the journey with an optional note + (appended to the patient-visible timeline). +- **Upload results** — attach a PDF/image to a request (stored in Firebase + Storage) plus an optional summary; sets status to *Results available*. +- **Appointment slots** — create slots, activate/deactivate them. + +## Architecture + +``` +lib/ + core/ enums (status/relation), theme, formatting, AdminException + models/ HlaTestRequest, HlaSampleSubject, HlaStatusEvent, HlaAppointmentSlot + services/ AuthService (Google popup), HlaAdminService (Firestore + Storage) + repositories/HlaAdminRepository (status events, result upload, slot release) + providers/ Riverpod (no codegen): auth + admin gate, requests/slots streams, mutations + screens/ app_root (auth gate) -> sign_in / not_authorized / dashboard + dashboard -> requests (master/detail) + slots + widgets/ status chip, status timeline +``` + +State: `flutter_riverpod` (no build_runner). Errors flow through `AsyncValue` and +surface as snackbars. + +## One-time setup + +The committed `lib/firebase_options.dart` has **placeholder** `apiKey`/`appId` — +a Web app must be registered on the Firebase project first. + +1. **Install deps** + ```bash + cd admin + flutter pub get + ``` + +2. **Add web Firebase config** (from `admin/`): + ```bash + flutterfire configure --project=circle-60af7 --platforms=web + ``` + This rewrites `lib/firebase_options.dart` with the real web credentials. + (Or register a Web app in the Firebase console and paste `apiKey` + `appId`.) + +3. **Enable Google sign-in**: Firebase console -> Authentication -> Sign-in + method -> enable **Google**. Under Authentication -> Settings -> Authorized + domains, add `localhost` (for local dev) and your eventual hosting domain. + +4. **Deploy the security rules + indexes** (from the repo root — these are shared + with the mobile feature and live in `../firestore.rules`, `../storage.rules`): + ```bash + firebase deploy --only firestore:rules,firestore:indexes,storage + ``` + +5. **Provision admins**: the dashboard gate (and the security rules) require an + `hla_admins/{uid}` document. For each staff member, after they sign in once + (to create their Firebase Auth uid), add an empty document at + `hla_admins/{their-uid}` in Firestore. + +## Run / build + +```bash +cd admin +flutter run -d chrome # local dev +flutter build web # production bundle -> build/web +flutter test # unit tests +``` + +To host it: `firebase deploy --only hosting` after configuring a Hosting target +that serves `admin/build/web` (not yet wired in `firebase.json`). + +## Notes + +- The admin gate is **authoritative via `hla_admins`**, not the user's profile + `role` field — a non-admin who signs in sees an "access denied" screen and the + rules reject any write. +- Result uploads use `putData` (web has bytes, not file paths). +- This app resolves to newer Firebase/Riverpod majors than the mobile app; that's + fine since it's an independent package. From 95e5183d918530f580c514dcf8edd0d0927734db Mon Sep 17 00:00:00 2001 From: Lewis Date: Thu, 25 Jun 2026 11:47:31 +0100 Subject: [PATCH 32/33] feat: add sign in with email --- admin/README.md | 8 +- admin/lib/screens/sign_in_screen.dart | 196 ++++++++++++++++++++------ admin/lib/services/auth_service.dart | 39 ++++- 3 files changed, 196 insertions(+), 47 deletions(-) diff --git a/admin/README.md b/admin/README.md index 9182a2a..82c40e7 100644 --- a/admin/README.md +++ b/admin/README.md @@ -55,9 +55,11 @@ a Web app must be registered on the Firebase project first. This rewrites `lib/firebase_options.dart` with the real web credentials. (Or register a Web app in the Firebase console and paste `apiKey` + `appId`.) -3. **Enable Google sign-in**: Firebase console -> Authentication -> Sign-in - method -> enable **Google**. Under Authentication -> Settings -> Authorized - domains, add `localhost` (for local dev) and your eventual hosting domain. +3. **Enable sign-in providers**: Firebase console -> Authentication -> Sign-in + method -> enable **Google** and **Email/Password** (the dashboard offers + both). Under Authentication -> Settings -> Authorized domains, add `localhost` + (for local dev) and your eventual hosting domain. Email/password admins are + created in Authentication -> Users. 4. **Deploy the security rules + indexes** (from the repo root — these are shared with the mobile feature and live in `../firestore.rules`, `../storage.rules`): diff --git a/admin/lib/screens/sign_in_screen.dart b/admin/lib/screens/sign_in_screen.dart index 9d7e037..c31e44b 100644 --- a/admin/lib/screens/sign_in_screen.dart +++ b/admin/lib/screens/sign_in_screen.dart @@ -13,65 +13,155 @@ class SignInScreen extends ConsumerStatefulWidget { } class _SignInScreenState extends ConsumerState { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + bool _obscure = true; bool _busy = false; - Future _signIn() async { + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + Future _run(Future Function() action) async { setState(() => _busy = true); try { - await ref.read(authServiceProvider).signInWithGoogle(); + await action(); // The auth-state listener in AppRoot routes onward automatically. } on AdminException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.message))); - } + _showError(e.message); } finally { if (mounted) setState(() => _busy = false); } } + Future _signInWithGoogle() => + _run(() => ref.read(authServiceProvider).signInWithGoogle()); + + Future _signInWithEmail() { + if (!_formKey.currentState!.validate()) return Future.value(); + return _run(() => ref.read(authServiceProvider).signInWithEmail( + email: _emailController.text, + password: _passwordController.text, + )); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 380), - child: Card( - child: Padding( - padding: const EdgeInsets.all(kGap32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 28, - backgroundColor: theme.colorScheme.primaryContainer, - child: Icon(Icons.biotech_outlined, - color: theme.colorScheme.onPrimaryContainer), - ), - const SizedBox(height: kGap16), - Text('Circle · HLA Admin', - style: theme.textTheme.titleLarge), - const SizedBox(height: kGap8), - Text( - 'Staff access only. Sign in with your authorized Google account.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium - ?.copyWith(color: theme.colorScheme.outline), - ), - const SizedBox(height: kGap24), - FilledButton.icon( - onPressed: _busy ? null : _signIn, - icon: _busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.login), - label: const Text('Sign in with Google'), - ), - ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(kGap24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + child: Padding( + padding: const EdgeInsets.all(kGap32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.biotech_outlined, + color: theme.colorScheme.onPrimaryContainer), + ), + const SizedBox(height: kGap16), + Text('Circle · HLA Admin', + style: theme.textTheme.titleLarge), + const SizedBox(height: kGap8), + Text( + 'Staff access only.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.outline), + ), + ], + ), + const SizedBox(height: kGap24), + + // ── Email + password ── + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailController, + enabled: !_busy, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username], + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.mail_outline), + ), + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Enter your email' + : null, + ), + const SizedBox(height: kGap12), + TextFormField( + controller: _passwordController, + enabled: !_busy, + obscureText: _obscure, + autofillHints: const [AutofillHints.password], + onFieldSubmitted: (_) => _signInWithEmail(), + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + onPressed: () => + setState(() => _obscure = !_obscure), + icon: Icon(_obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + ), + ), + validator: (v) => (v == null || v.isEmpty) + ? 'Enter your password' + : null, + ), + const SizedBox(height: kGap16), + FilledButton( + onPressed: _busy ? null : _signInWithEmail, + child: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Sign in'), + ), + ], + ), + ), + + const SizedBox(height: kGap16), + const _OrDivider(), + const SizedBox(height: kGap16), + + // ── Google ── + OutlinedButton.icon( + onPressed: _busy ? null : _signInWithGoogle, + icon: const Icon(Icons.login), + label: const Text('Sign in with Google'), + ), + ], + ), ), ), ), @@ -80,3 +170,23 @@ class _SignInScreenState extends ConsumerState { ); } } + +class _OrDivider extends StatelessWidget { + const _OrDivider(); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.outlineVariant; + return Row( + children: [ + Expanded(child: Divider(color: color)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: kGap12), + child: Text('or', + style: TextStyle(color: Theme.of(context).colorScheme.outline)), + ), + Expanded(child: Divider(color: color)), + ], + ); + } +} diff --git a/admin/lib/services/auth_service.dart b/admin/lib/services/auth_service.dart index b55420d..4198b9a 100644 --- a/admin/lib/services/auth_service.dart +++ b/admin/lib/services/auth_service.dart @@ -17,9 +17,46 @@ class AuthService { ..setCustomParameters({'prompt': 'select_account'}); return await _auth.signInWithPopup(provider); } on FirebaseAuthException catch (e) { - throw AdminException(e.message ?? 'Sign-in failed', cause: e); + throw AdminException(_friendlyMessage(e), cause: e); + } + } + + Future signInWithEmail({ + required String email, + required String password, + }) async { + try { + return await _auth.signInWithEmailAndPassword( + email: email.trim(), + password: password, + ); + } on FirebaseAuthException catch (e) { + throw AdminException(_friendlyMessage(e), cause: e); } } Future signOut() => _auth.signOut(); + + /// Maps Firebase auth error codes to short, user-facing messages. + String _friendlyMessage(FirebaseAuthException e) { + switch (e.code) { + case 'invalid-email': + return 'That email address looks invalid.'; + case 'user-disabled': + return 'This account has been disabled.'; + case 'user-not-found': + case 'wrong-password': + case 'invalid-credential': + return 'Incorrect email or password.'; + case 'too-many-requests': + return 'Too many attempts. Please try again in a moment.'; + case 'network-request-failed': + return 'Network error. Check your connection and try again.'; + case 'popup-closed-by-user': + case 'cancelled-popup-request': + return 'Sign-in was cancelled.'; + default: + return e.message ?? 'Sign-in failed.'; + } + } } From 435d1056ffb3cc79a19b970d0fa12464919d2cca Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 29 Jun 2026 12:33:13 +0100 Subject: [PATCH 33/33] chore [config]: properly setup firebase for web, and firebase auth --- admin/firebase.json | 14 ++++++++++++++ admin/lib/firebase_options.dart | 13 ++++++++----- admin/lib/main.dart | 3 ++- admin/web/index.html | 31 +++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 admin/firebase.json diff --git a/admin/firebase.json b/admin/firebase.json new file mode 100644 index 0000000..4bddfb6 --- /dev/null +++ b/admin/firebase.json @@ -0,0 +1,14 @@ +{ + "flutter": { + "platforms": { + "dart": { + "lib/firebase_options.dart": { + "projectId": "circle-60af7", + "configurations": { + "web": "1:497391679746:web:7a3f50c4e6f710119d2a1b" + } + } + } + } + } +} \ No newline at end of file diff --git a/admin/lib/firebase_options.dart b/admin/lib/firebase_options.dart index cdef9a9..84ad385 100644 --- a/admin/lib/firebase_options.dart +++ b/admin/lib/firebase_options.dart @@ -14,13 +14,16 @@ import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; /// /// The non-secret identifiers (projectId, messagingSenderId, storageBucket) /// are shared with the mobile app and already filled in. -class DefaultFirebaseOptions { - static const FirebaseOptions web = FirebaseOptions( - apiKey: 'REPLACE_WITH_WEB_API_KEY', - appId: 'REPLACE_WITH_WEB_APP_ID', + + const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyBdJoJ6xP2VH2fuAS4oUtQ1qQ2xDoIn5o8', + appId: '1:497391679746:web:7a3f50c4e6f710119d2a1b', messagingSenderId: '497391679746', projectId: 'circle-60af7', authDomain: 'circle-60af7.firebaseapp.com', storageBucket: 'circle-60af7.firebasestorage.app', + measurementId: 'G-3KQHB7RSVC', ); -} + +class DefaultFirebaseOptions { +} \ No newline at end of file diff --git a/admin/lib/main.dart b/admin/lib/main.dart index 442eaab..5547241 100644 --- a/admin/lib/main.dart +++ b/admin/lib/main.dart @@ -2,13 +2,14 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'core/theme.dart'; import 'firebase_options.dart'; import 'screens/app_root.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.web); + await Firebase.initializeApp(options: web); runApp(const ProviderScope(child: HlaAdminApp())); } diff --git a/admin/web/index.html b/admin/web/index.html index 02c6f45..97812db 100644 --- a/admin/web/index.html +++ b/admin/web/index.html @@ -29,6 +29,9 @@ + + + hla_admin @@ -42,5 +45,33 @@ * https://docs.flutter.dev/platform-integration/web/initialization --> + + + +