Merge pull request #561 from Oloodi/493-implement-rapid-order-creation-voice-text-in-client-mobile-app

Fix issues in the staff and client mobile apps
This commit is contained in:
Achintha Isuru
2026-03-02 01:00:22 -05:00
committed by GitHub
131 changed files with 4329 additions and 3547 deletions

2
.gitignore vendored
View File

@@ -119,7 +119,6 @@ vite.config.ts.timestamp-*
# Android # Android
.gradle/ .gradle/
**/android/app/libs/ **/android/app/libs/
**/android/key.properties
**/android/local.properties **/android/local.properties
# Build outputs # Build outputs
@@ -193,3 +192,4 @@ AGENTS.md
CLAUDE.md CLAUDE.md
GEMINI.md GEMINI.md
TASKS.md TASKS.md
\n# Android Signing (Secure)\n**.jks\n**key.properties

View File

@@ -26,7 +26,60 @@ The project is organized into modular packages to ensure separation of concerns
### 1. Prerequisites ### 1. Prerequisites
Ensure you have the Flutter SDK installed and configured. Ensure you have the Flutter SDK installed and configured.
### 2. Initial Setup ### 2. Android Keystore Setup (Required for Release Builds)
To build release APKs/AABs for Android, you need the signing keystores. The keystore configuration (`key.properties`) is committed to the repository, but the actual keystore files are **not** for security reasons.
#### For Local Development (First-time Setup)
Contact your team lead to obtain the keystore files:
- `krow_with_us_client_dev.jks` - Client app signing keystore
- `krow_with_us_staff_dev.jks` - Staff app signing keystore
Once you have the keystores, copy them to the respective app directories:
```bash
# Copy keystores to their locations
cp krow_with_us_client_dev.jks apps/mobile/apps/client/android/app/
cp krow_with_us_staff_dev.jks apps/mobile/apps/staff/android/app/
```
The `key.properties` configuration files are already in the repository:
- `apps/mobile/apps/client/android/key.properties`
- `apps/mobile/apps/staff/android/key.properties`
No manual property file creation is needed — just place the `.jks` files in the correct locations.
#### For CI/CD (CodeMagic)
CodeMagic uses a native keystore management system. Follow these steps:
**Step 1: Upload Keystores to CodeMagic**
1. Go to **CodeMagic Team Settings****Code signing identities****Android keystores**
2. Upload the keystore files with these **Reference names** (important!):
- `krow_client_dev` (for dev builds)
- `krow_client_staging` (for staging builds)
- `krow_client_prod` (for production builds)
- `krow_staff_dev` (for dev builds)
- `krow_staff_staging` (for staging builds)
- `krow_staff_prod` (for production builds)
3. When uploading, enter the keystore password, key alias, and key password for each keystore
**Step 2: Automatic Environment Variables**
CodeMagic automatically injects the following environment variables based on the keystore reference:
- `CM_KEYSTORE_PATH_CLIENT` / `CM_KEYSTORE_PATH_STAFF` - Path to the keystore file
- `CM_KEYSTORE_PASSWORD_CLIENT` / `CM_KEYSTORE_PASSWORD_STAFF` - Keystore password
- `CM_KEY_ALIAS_CLIENT` / `CM_KEY_ALIAS_STAFF` - Key alias
- `CM_KEY_PASSWORD_CLIENT` / `CM_KEY_PASSWORD_STAFF` - Key password
**Step 3: Build Configuration**
The `build.gradle.kts` files are already configured to:
- Use CodeMagic environment variables when running in CI (`CI=true`)
- Fall back to `key.properties` for local development
Reference: [CodeMagic Android Signing Documentation](https://docs.codemagic.io/yaml-code-signing/signing-android/)
### 3. Initial Setup
Run the following command from the **project root** to install Melos, bootstrap all packages, generate localization files, and generate the Firebase Data Connect SDK: Run the following command from the **project root** to install Melos, bootstrap all packages, generate localization files, and generate the Firebase Data Connect SDK:
```bash ```bash
@@ -42,7 +95,7 @@ This command will:
**Note:** The Firebase Data Connect SDK files (`dataconnect_generated/`) are auto-generated and not committed to the repository. They will be regenerated automatically when you run `make mobile-install` or any mobile development commands. **Note:** The Firebase Data Connect SDK files (`dataconnect_generated/`) are auto-generated and not committed to the repository. They will be regenerated automatically when you run `make mobile-install` or any mobile development commands.
### 3. Running the Apps ### 4. Running the Apps
You can run the applications using Melos scripts or through the `Makefile`: You can run the applications using Melos scripts or through the `Makefile`:
First, find your device ID: First, find your device ID:

View File

@@ -7,8 +7,7 @@ gradle-wrapper.jar
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore files.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks

View File

@@ -1,4 +1,5 @@
import java.util.Base64 import java.util.Base64
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
@@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach {
} }
} }
val keystoreProperties = Properties().apply {
val propertiesFile = rootProject.file("key.properties")
if (propertiesFile.exists()) {
load(propertiesFile.inputStream())
}
}
android { android {
namespace = "com.krowwithus.client" namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -47,11 +55,29 @@ android {
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
} }
signingConfigs {
create("release") {
if (System.getenv()["CI"] == "true") {
// CodeMagic CI environment
storeFile = file(System.getenv()["CM_KEYSTORE_PATH_CLIENT"] ?: "")
storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_CLIENT"]
keyAlias = System.getenv()["CM_KEY_ALIAS_CLIENT"]
keyPassword = System.getenv()["CM_KEY_PASSWORD_CLIENT"]
} else {
// Local development environment
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("release")
} }
} }
} }

View File

@@ -86,11 +86,11 @@
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com", "client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.krowwithus.client", "package_name": "com.krowwithus.client",
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280" "certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
} }
}, },
{ {
@@ -130,11 +130,11 @@
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", "client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.krowwithus.staff", "package_name": "com.krowwithus.staff",
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" "certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153"
} }
}, },
{ {

View File

@@ -0,0 +1,9 @@
storePassword=krowwithus
keyPassword=krowwithus
keyAlias=krow_client_dev
storeFile=krow_with_us_client_dev.jks
###
### Client
### SHA1: F5:49:1C:60:EC:20:EB:27:BB:3E:C5:81:35:2B:A6:53:05:3F:37:40
### SHA256: 27:88:E4:EB:6C:BF:8E:25:66:37:76:B3:5D:DA:92:8A:CB:1A:6F:24:F3:38:9B:EA:DE:F0:25:62:FD:7A:7E:77

View File

@@ -51,7 +51,10 @@ void main() async {
/// The main application module for the Client app. /// The main application module for the Client app.
class AppModule extends Module { class AppModule extends Module {
@override @override
List<Module> get imports => <Module>[core_localization.LocalizationModule()]; List<Module> get imports => <Module>[
core_localization.LocalizationModule(),
CoreModule(),
];
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
@@ -99,7 +102,9 @@ class AppWidget extends StatelessWidget {
>( >(
builder: builder:
(BuildContext context, core_localization.LocaleState state) { (BuildContext context, core_localization.LocaleState state) {
return core_localization.TranslationProvider( return KeyedSubtree(
key: ValueKey<Locale>(state.locale),
child: core_localization.TranslationProvider(
child: MaterialApp.router( child: MaterialApp.router(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: "KROW Client", title: "KROW Client",
@@ -114,6 +119,7 @@ class AppWidget extends StatelessWidget {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
), ),
),
); );
}, },
), ),

View File

@@ -74,7 +74,9 @@ class _SessionListenerState extends State<SessionListener> {
// Only show if not initial state (avoid showing on cold start) // Only show if not initial state (avoid showing on cold start)
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred',
);
} else { } else {
_isInitialState = false; _isInitialState = false;
Modular.to.toClientGetStartedPage(); Modular.to.toClientGetStartedPage();
@@ -126,7 +128,7 @@ class _SessionListenerState extends State<SessionListener> {
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // User can retry by dismissing and continuing
Modular.to.pop(); Modular.to.popSafe();
}, },
child: const Text('Continue'), child: const Text('Continue'),
), ),

View File

@@ -7,8 +7,7 @@ gradle-wrapper.jar
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore files.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks

View File

@@ -1,4 +1,5 @@
import java.util.Base64 import java.util.Base64
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
@@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach {
} }
} }
val keystoreProperties = Properties().apply {
val propertiesFile = rootProject.file("key.properties")
if (propertiesFile.exists()) {
load(propertiesFile.inputStream())
}
}
android { android {
namespace = "com.krowwithus.staff" namespace = "com.krowwithus.staff"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -47,11 +55,30 @@ android {
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
} }
signingConfigs {
create("release") {
if (System.getenv()["CI"] == "true") {
// CodeMagic CI environment
storeFile = file(System.getenv()["CM_KEYSTORE_PATH_STAFF"] ?: "")
storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_STAFF"]
keyAlias = System.getenv()["CM_KEY_ALIAS_STAFF"]
keyPassword = System.getenv()["CM_KEY_PASSWORD_STAFF"]
} else {
// Local development environment
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
}
buildTypes { buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("release")
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@@ -86,11 +86,11 @@
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com", "client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.krowwithus.client", "package_name": "com.krowwithus.client",
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280" "certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
} }
}, },
{ {
@@ -130,11 +130,11 @@
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", "client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "android_info": {
"package_name": "com.krowwithus.staff", "package_name": "com.krowwithus.staff",
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" "certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153"
} }
}, },
{ {

View File

@@ -0,0 +1,9 @@
storePassword=krowwithus
keyPassword=krowwithus
keyAlias=krow_staff_dev
storeFile=krow_with_us_staff_dev.jks
###
### Staff
### SHA1: A6:EF:7F:E8:AD:E3:13:E6:93:77:B1:78:54:41:92:D8:35:B2:91:53
### SHA256: 26:B5:BD:1A:DE:18:92:1F:A3:7B:59:99:5E:4E:D0:BB:DF:93:D6:F6:01:16:04:55:0F:AA:57:55:C1:6B:7D:95

View File

@@ -79,7 +79,9 @@ class AppWidget extends StatelessWidget {
>( >(
builder: builder:
(BuildContext context, core_localization.LocaleState state) { (BuildContext context, core_localization.LocaleState state) {
return core_localization.TranslationProvider( return KeyedSubtree(
key: ValueKey<Locale>(state.locale),
child: core_localization.TranslationProvider(
child: MaterialApp.router( child: MaterialApp.router(
title: "KROW Staff", title: "KROW Staff",
theme: UiTheme.light, theme: UiTheme.light,
@@ -93,6 +95,7 @@ class AppWidget extends StatelessWidget {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
), ),
),
); );
}, },
), ),

View File

@@ -65,7 +65,6 @@ class _SessionListenerState extends State<SessionListener> {
_sessionExpiredDialogShown = false; _sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}'); debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Navigate to the main app // Navigate to the main app
Modular.to.toStaffHome(); Modular.to.toStaffHome();
break; break;
@@ -75,7 +74,9 @@ class _SessionListenerState extends State<SessionListener> {
// Only show if not initial state (avoid showing on cold start) // Only show if not initial state (avoid showing on cold start)
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred',
);
} else { } else {
_isInitialState = false; _isInitialState = false;
Modular.to.toGetStartedPage(); Modular.to.toGetStartedPage();
@@ -127,7 +128,7 @@ class _SessionListenerState extends State<SessionListener> {
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // User can retry by dismissing and continuing
Modular.to.pop(); Modular.to.popSafe();
}, },
child: const Text('Continue'), child: const Text('Continue'),
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../navigation_extensions.dart';
import 'route_paths.dart'; import 'route_paths.dart';
/// Typed navigation extension for the Client application. /// Typed navigation extension for the Client application.
@@ -33,14 +34,14 @@ extension ClientNavigator on IModularNavigator {
/// This effectively logs out the user by navigating to root. /// This effectively logs out the user by navigating to root.
/// Used when signing out or session expires. /// Used when signing out or session expires.
void toClientRoot() { void toClientRoot() {
navigate(ClientPaths.root); safeNavigate(ClientPaths.root);
} }
/// Navigates to the get started page. /// Navigates to the get started page.
/// ///
/// This is the landing page for unauthenticated users, offering login/signup options. /// This is the landing page for unauthenticated users, offering login/signup options.
void toClientGetStartedPage() { void toClientGetStartedPage() {
navigate(ClientPaths.getStarted); safeNavigate(ClientPaths.getStarted);
} }
/// Navigates to the client sign-in page. /// Navigates to the client sign-in page.
@@ -48,7 +49,7 @@ extension ClientNavigator on IModularNavigator {
/// This page allows existing clients to log in using email/password /// This page allows existing clients to log in using email/password
/// or social authentication providers. /// or social authentication providers.
void toClientSignIn() { void toClientSignIn() {
pushNamed(ClientPaths.signIn); safePush(ClientPaths.signIn);
} }
/// Navigates to the client sign-up page. /// Navigates to the client sign-up page.
@@ -56,7 +57,7 @@ extension ClientNavigator on IModularNavigator {
/// This page allows new clients to create an account and provides /// This page allows new clients to create an account and provides
/// the initial registration form. /// the initial registration form.
void toClientSignUp() { void toClientSignUp() {
pushNamed(ClientPaths.signUp); safePush(ClientPaths.signUp);
} }
/// Navigates to the client home dashboard. /// Navigates to the client home dashboard.
@@ -66,7 +67,7 @@ extension ClientNavigator on IModularNavigator {
/// ///
/// Uses pushNamed to avoid trailing slash issues with navigate(). /// Uses pushNamed to avoid trailing slash issues with navigate().
void toClientHome() { void toClientHome() {
navigate(ClientPaths.home); safeNavigate(ClientPaths.home);
} }
/// Navigates to the client main shell. /// Navigates to the client main shell.
@@ -74,7 +75,7 @@ extension ClientNavigator on IModularNavigator {
/// This is the container with bottom navigation. Usually you'd navigate /// This is the container with bottom navigation. Usually you'd navigate
/// to a specific tab instead (like [toClientHome]). /// to a specific tab instead (like [toClientHome]).
void toClientMain() { void toClientMain() {
navigate(ClientPaths.main); safeNavigate(ClientPaths.main);
} }
// ========================================================================== // ==========================================================================
@@ -85,43 +86,43 @@ extension ClientNavigator on IModularNavigator {
/// ///
/// Displays workforce coverage analytics and metrics. /// Displays workforce coverage analytics and metrics.
void toClientCoverage() { void toClientCoverage() {
navigate(ClientPaths.coverage); safeNavigate(ClientPaths.coverage);
} }
/// Navigates to the Billing tab. /// Navigates to the Billing tab.
/// ///
/// Access billing history, invoices, and payment methods. /// Access billing history, invoices, and payment methods.
void toClientBilling() { void toClientBilling() {
navigate(ClientPaths.billing); safeNavigate(ClientPaths.billing);
} }
/// Navigates to the Completion Review page. /// Navigates to the Completion Review page.
void toCompletionReview({Object? arguments}) { void toCompletionReview({Object? arguments}) {
pushNamed(ClientPaths.completionReview, arguments: arguments); safePush(ClientPaths.completionReview, arguments: arguments);
} }
/// Navigates to the full list of invoices awaiting approval. /// Navigates to the full list of invoices awaiting approval.
void toAwaitingApproval({Object? arguments}) { Future<Object?> toAwaitingApproval({Object? arguments}) {
pushNamed(ClientPaths.awaitingApproval, arguments: arguments); return safePush(ClientPaths.awaitingApproval, arguments: arguments);
} }
/// Navigates to the Invoice Ready page. /// Navigates to the Invoice Ready page.
void toInvoiceReady() { void toInvoiceReady() {
pushNamed(ClientPaths.invoiceReady); safePush(ClientPaths.invoiceReady);
} }
/// Navigates to the Orders tab. /// Navigates to the Orders tab.
/// ///
/// View and manage all shift orders with filtering and sorting. /// View and manage all shift orders with filtering and sorting.
void toClientOrders() { void toClientOrders() {
navigate(ClientPaths.orders); safeNavigate(ClientPaths.orders);
} }
/// Navigates to the Reports tab. /// Navigates to the Reports tab.
/// ///
/// Generate and view workforce reports and analytics. /// Generate and view workforce reports and analytics.
void toClientReports() { void toClientReports() {
navigate(ClientPaths.reports); safeNavigate(ClientPaths.reports);
} }
// ========================================================================== // ==========================================================================
@@ -132,12 +133,12 @@ extension ClientNavigator on IModularNavigator {
/// ///
/// Manage account settings, notifications, and app preferences. /// Manage account settings, notifications, and app preferences.
void toClientSettings() { void toClientSettings() {
pushNamed(ClientPaths.settings); safePush(ClientPaths.settings);
} }
/// Pushes the edit profile page. /// Pushes the edit profile page.
void toClientEditProfile() { void toClientEditProfile() {
pushNamed('${ClientPaths.settings}/edit-profile'); safePush('${ClientPaths.settings}/edit-profile');
} }
// ========================================================================== // ==========================================================================
@@ -148,12 +149,12 @@ extension ClientNavigator on IModularNavigator {
/// ///
/// View and manage physical locations/hubs where staff are deployed. /// View and manage physical locations/hubs where staff are deployed.
Future<void> toClientHubs() async { Future<void> toClientHubs() async {
await pushNamed(ClientPaths.hubs); await safePush(ClientPaths.hubs);
} }
/// Navigates to the details of a specific hub. /// Navigates to the details of a specific hub.
Future<bool?> toHubDetails(Hub hub) { Future<bool?> toHubDetails(Hub hub) {
return pushNamed<bool?>( return safePush<bool?>(
ClientPaths.hubDetails, ClientPaths.hubDetails,
arguments: <String, dynamic>{'hub': hub}, arguments: <String, dynamic>{'hub': hub},
); );
@@ -161,7 +162,7 @@ extension ClientNavigator on IModularNavigator {
/// Navigates to the page to add a new hub or edit an existing one. /// Navigates to the page to add a new hub or edit an existing one.
Future<bool?> toEditHub({Hub? hub}) async { Future<bool?> toEditHub({Hub? hub}) async {
return pushNamed<bool?>( return safePush<bool?>(
ClientPaths.editHub, ClientPaths.editHub,
arguments: <String, dynamic>{'hub': hub}, arguments: <String, dynamic>{'hub': hub},
// Some versions of Modular allow passing opaque here, but if not // Some versions of Modular allow passing opaque here, but if not
@@ -178,35 +179,35 @@ extension ClientNavigator on IModularNavigator {
/// ///
/// This is the starting point for all order creation flows. /// This is the starting point for all order creation flows.
void toCreateOrder({Object? arguments}) { void toCreateOrder({Object? arguments}) {
navigate(ClientPaths.createOrder, arguments: arguments); safeNavigate(ClientPaths.createOrder, arguments: arguments);
} }
/// Pushes the rapid order creation flow. /// Pushes the rapid order creation flow.
/// ///
/// Quick shift creation with simplified inputs for urgent needs. /// Quick shift creation with simplified inputs for urgent needs.
void toCreateOrderRapid({Object? arguments}) { void toCreateOrderRapid({Object? arguments}) {
pushNamed(ClientPaths.createOrderRapid, arguments: arguments); safePush(ClientPaths.createOrderRapid, arguments: arguments);
} }
/// Pushes the one-time order creation flow. /// Pushes the one-time order creation flow.
/// ///
/// Create a shift that occurs once at a specific date and time. /// Create a shift that occurs once at a specific date and time.
void toCreateOrderOneTime({Object? arguments}) { void toCreateOrderOneTime({Object? arguments}) {
pushNamed(ClientPaths.createOrderOneTime, arguments: arguments); safePush(ClientPaths.createOrderOneTime, arguments: arguments);
} }
/// Pushes the recurring order creation flow. /// Pushes the recurring order creation flow.
/// ///
/// Create shifts that repeat on a defined schedule (daily, weekly, etc.). /// Create shifts that repeat on a defined schedule (daily, weekly, etc.).
void toCreateOrderRecurring({Object? arguments}) { void toCreateOrderRecurring({Object? arguments}) {
pushNamed(ClientPaths.createOrderRecurring, arguments: arguments); safePush(ClientPaths.createOrderRecurring, arguments: arguments);
} }
/// Pushes the permanent order creation flow. /// Pushes the permanent order creation flow.
/// ///
/// Create a long-term or permanent staffing position. /// Create a long-term or permanent staffing position.
void toCreateOrderPermanent({Object? arguments}) { void toCreateOrderPermanent({Object? arguments}) {
pushNamed(ClientPaths.createOrderPermanent, arguments: arguments); safePush(ClientPaths.createOrderPermanent, arguments: arguments);
} }
// ========================================================================== // ==========================================================================
@@ -215,7 +216,7 @@ extension ClientNavigator on IModularNavigator {
/// Navigates to the order details page to a specific date. /// Navigates to the order details page to a specific date.
void toOrdersSpecificDate(DateTime date) { void toOrdersSpecificDate(DateTime date) {
navigate( safeNavigate(
ClientPaths.orders, ClientPaths.orders,
arguments: <String, DateTime>{'initialDate': date}, arguments: <String, DateTime>{'initialDate': date},
); );

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'client/route_paths.dart';
import 'staff/route_paths.dart';
/// Base navigation utilities extension for [IModularNavigator]. /// Base navigation utilities extension for [IModularNavigator].
/// ///
@@ -21,17 +24,15 @@ extension NavigationExtensions on IModularNavigator {
/// * [arguments] - Optional arguments to pass to the route /// * [arguments] - Optional arguments to pass to the route
/// ///
/// Returns `true` if navigation was successful, `false` otherwise. /// Returns `true` if navigation was successful, `false` otherwise.
Future<bool> safeNavigate( Future<bool> safeNavigate(String path, {Object? arguments}) async {
String path, {
Object? arguments,
}) async {
try { try {
navigate(path, arguments: arguments); navigate(path, arguments: arguments);
return true; return true;
} catch (e) { } catch (e) {
// In production, you might want to log this to a monitoring service // In production, you might want to log this to a monitoring service
// ignore: avoid_print // ignore: avoid_debugPrint
print('Navigation error to $path: $e'); debugPrint('Navigation error to $path: $e');
navigateToHome();
return false; return false;
} }
} }
@@ -54,8 +55,30 @@ extension NavigationExtensions on IModularNavigator {
return await pushNamed<T>(routeName, arguments: arguments); return await pushNamed<T>(routeName, arguments: arguments);
} catch (e) { } catch (e) {
// In production, you might want to log this to a monitoring service // In production, you might want to log this to a monitoring service
// ignore: avoid_print // ignore: avoid_debugPrint
print('Push navigation error to $routeName: $e'); debugPrint('Push navigation error to $routeName: $e');
navigateToHome();
return null;
}
}
/// Safely pushes a named route and removes until a predicate is met.
Future<T?> safePushNamedAndRemoveUntil<T extends Object?>(
String routeName,
bool Function(Route<dynamic>) predicate, {
Object? arguments,
}) async {
try {
return await pushNamedAndRemoveUntil<T>(
routeName,
predicate,
arguments: arguments,
);
} catch (e) {
// In production, you might want to log this to a monitoring service
// ignore: avoid_debugPrint
debugPrint('PushNamedAndRemoveUntil error to $routeName: $e');
navigateToHome();
return null; return null;
} }
} }
@@ -68,14 +91,31 @@ extension NavigationExtensions on IModularNavigator {
navigate('/'); navigate('/');
} }
/// Pops the current route if possible. /// Pops the current route if possible, otherwise navigates to home.
/// ///
/// Returns `true` if a route was popped, `false` if already at root. /// Returns `true` if a route was popped, `false` if it navigated to home.
bool popSafe() { bool popSafe<T extends Object?>([T? result]) {
if (canPop()) { if (canPop()) {
pop(); pop(result);
return true; return true;
} }
navigateToHome();
return false; return false;
} }
/// Navigates to the designated home page based on the current context.
///
/// Checks the current path to determine if the user is in the Client
/// or Staff portion of the application and routes to their respective home.
void navigateToHome() {
final String currentPath = Modular.to.path;
if (currentPath.contains('/client')) {
navigate(ClientPaths.home);
} else if (currentPath.contains('/worker') ||
currentPath.contains('/staff')) {
navigate(StaffPaths.home);
} else {
navigate('/');
}
}
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../navigation_extensions.dart';
import 'route_paths.dart'; import 'route_paths.dart';
/// Typed navigation extension for the Staff application. /// Typed navigation extension for the Staff application.
@@ -33,76 +34,36 @@ extension StaffNavigator on IModularNavigator {
/// This effectively logs out the user by navigating to root. /// This effectively logs out the user by navigating to root.
/// Used when signing out or session expires. /// Used when signing out or session expires.
void toInitialPage() { void toInitialPage() {
navigate(StaffPaths.root); safeNavigate(StaffPaths.root);
} }
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toGetStartedPage() { void toGetStartedPage() {
navigate(StaffPaths.getStarted); safeNavigate(StaffPaths.getStarted);
} }
/// Navigates to the phone verification page.
///
/// Used for both login and signup flows to verify phone numbers via OTP.
///
/// Parameters:
/// * [mode] - The authentication mode: 'login' or 'signup'
///
/// The mode is passed as an argument and used by the verification page
/// to determine the appropriate flow.
void toPhoneVerification(String mode) { void toPhoneVerification(String mode) {
pushNamed( safePush(
StaffPaths.phoneVerification, StaffPaths.phoneVerification,
arguments: <String, String>{'mode': mode}, arguments: <String, String>{'mode': mode},
); );
} }
/// Navigates to the profile setup page, replacing the current route.
///
/// This is typically called after successful phone verification for new
/// staff members. Uses pushReplacement to prevent going back to verification.
void toProfileSetup() { void toProfileSetup() {
pushNamed(StaffPaths.profileSetup); safePush(StaffPaths.profileSetup);
} }
// ==========================================================================
// MAIN NAVIGATION
// ==========================================================================
/// Navigates to the staff home dashboard.
///
/// This is the main landing page for authenticated staff members.
/// Displays shift cards, quick actions, and notifications.
void toStaffHome() { void toStaffHome() {
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false); safePushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
} }
/// Navigates to the benefits overview page.
void toBenefits() { void toBenefits() {
pushNamed(StaffPaths.benefits); safePush(StaffPaths.benefits);
} }
/// Navigates to the staff main shell.
///
/// This is the container with bottom navigation. Navigates to home tab
/// by default. Usually you'd navigate to a specific tab instead.
void toStaffMain() { void toStaffMain() {
pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
} }
// ==========================================================================
// MAIN NAVIGATION TABS
// ==========================================================================
/// Navigates to the Shifts tab.
///
/// Browse available shifts, accepted shifts, and shift history.
///
/// Parameters:
/// * [selectedDate] - Optional date to pre-select in the shifts view
/// * [initialTab] - Optional initial tab (via query parameter)
void toShifts({ void toShifts({
DateTime? selectedDate, DateTime? selectedDate,
String? initialTab, String? initialTab,
@@ -118,94 +79,47 @@ extension StaffNavigator on IModularNavigator {
if (refreshAvailable == true) { if (refreshAvailable == true) {
args['refreshAvailable'] = true; args['refreshAvailable'] = true;
} }
navigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args); safeNavigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args);
} }
/// Navigates to the Payments tab.
///
/// View payment history, earnings breakdown, and tax information.
void toPayments() { void toPayments() {
pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false); safePushNamedAndRemoveUntil(StaffPaths.payments, (_) => false);
} }
/// Navigates to the Clock In tab.
///
/// Access time tracking interface for active shifts.
void toClockIn() { void toClockIn() {
pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false); safePushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false);
} }
/// Navigates to the Profile tab.
///
/// Manage personal information, documents, and preferences.
void toProfile() { void toProfile() {
navigate(StaffPaths.profile); safeNavigate(StaffPaths.profile);
} }
// ==========================================================================
// SHIFT MANAGEMENT
// ==========================================================================
/// Navigates to the shift details page for a specific shift.
///
/// Displays comprehensive information about a shift including location,
/// time, pay rate, and action buttons for accepting/declining/applying.
///
/// Parameters:
/// * [shift] - The shift entity to display details for
///
/// The shift object is passed as an argument and can be retrieved
/// in the details page.
void toShiftDetails(Shift shift) { void toShiftDetails(Shift shift) {
navigate(StaffPaths.shiftDetails(shift.id), arguments: shift); safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
} }
// ==========================================================================
// ONBOARDING & PROFILE SECTIONS
// ==========================================================================
/// Pushes the personal information page.
///
/// Collect or edit basic personal information.
void toPersonalInfo() { void toPersonalInfo() {
pushNamed(StaffPaths.onboardingPersonalInfo); safePush(StaffPaths.onboardingPersonalInfo);
} }
/// Pushes the preferred locations editing page.
///
/// Allows staff to search and manage their preferred US work locations.
void toPreferredLocations() { void toPreferredLocations() {
pushNamed(StaffPaths.preferredLocations); safePush(StaffPaths.preferredLocations);
} }
/// Pushes the emergency contact page.
///
/// Manage emergency contact details for safety purposes.
void toEmergencyContact() { void toEmergencyContact() {
pushNamed(StaffPaths.emergencyContact); safePush(StaffPaths.emergencyContact);
} }
/// Pushes the work experience page.
///
/// Record previous work experience and qualifications.
void toExperience() { void toExperience() {
navigate(StaffPaths.experience); safeNavigate(StaffPaths.experience);
} }
/// Pushes the attire preferences page.
///
/// Record sizing and appearance information for uniform allocation.
void toAttire() { void toAttire() {
navigate(StaffPaths.attire); safeNavigate(StaffPaths.attire);
} }
/// Pushes the attire capture page.
///
/// Parameters:
/// * [item] - The attire item to capture
/// * [initialPhotoUrl] - Optional initial photo URL
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
navigate( safeNavigate(
StaffPaths.attireCapture, StaffPaths.attireCapture,
arguments: <String, dynamic>{ arguments: <String, dynamic>{
'item': item, 'item': item,
@@ -214,24 +128,12 @@ extension StaffNavigator on IModularNavigator {
); );
} }
// ==========================================================================
// COMPLIANCE & DOCUMENTS
// ==========================================================================
/// Pushes the documents management page.
///
/// Upload and manage required documents like ID and work permits.
void toDocuments() { void toDocuments() {
navigate(StaffPaths.documents); safeNavigate(StaffPaths.documents);
} }
/// Pushes the document upload page.
///
/// Parameters:
/// * [document] - The document metadata to upload
/// * [initialUrl] - Optional initial document URL
void toDocumentUpload({required StaffDocument document, String? initialUrl}) { void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
navigate( safeNavigate(
StaffPaths.documentUpload, StaffPaths.documentUpload,
arguments: <String, dynamic>{ arguments: <String, dynamic>{
'document': document, 'document': document,
@@ -240,124 +142,71 @@ extension StaffNavigator on IModularNavigator {
); );
} }
/// Pushes the certificates management page.
///
/// Manage professional certificates (e.g., food handling, CPR).
void toCertificates() { void toCertificates() {
pushNamed(StaffPaths.certificates); safePush(StaffPaths.certificates);
} }
// ==========================================================================
// FINANCIAL INFORMATION
// ==========================================================================
/// Pushes the bank account information page.
///
/// Manage banking details for direct deposit payments.
void toBankAccount() { void toBankAccount() {
pushNamed(StaffPaths.bankAccount); safePush(StaffPaths.bankAccount);
} }
/// Pushes the tax forms page.
///
/// Manage W-4, tax withholding, and related tax documents.
void toTaxForms() { void toTaxForms() {
pushNamed(StaffPaths.taxForms); safePush(StaffPaths.taxForms);
}
void toLanguageSelection() {
safePush(StaffPaths.languageSelection);
}
void toFormI9() {
safeNavigate(StaffPaths.formI9);
}
void toFormW4() {
safeNavigate(StaffPaths.formW4);
} }
/// Pushes the time card page.
///
/// View detailed time entries and timesheets.
void toTimeCard() { void toTimeCard() {
pushNamed(StaffPaths.timeCard); safePush(StaffPaths.timeCard);
} }
// ==========================================================================
// SCHEDULING & AVAILABILITY
// ==========================================================================
/// Pushes the availability management page.
///
/// Define when the staff member is available to work.
void toAvailability() { void toAvailability() {
pushNamed(StaffPaths.availability); safePush(StaffPaths.availability);
} }
// ==========================================================================
// ADDITIONAL FEATURES
// ==========================================================================
/// Pushes the KROW University page (placeholder).
///
/// Access training materials and educational courses.
void toKrowUniversity() { void toKrowUniversity() {
pushNamed(StaffPaths.krowUniversity); safePush(StaffPaths.krowUniversity);
} }
/// Pushes the trainings page (placeholder).
///
/// View and complete required training modules.
void toTrainings() { void toTrainings() {
pushNamed(StaffPaths.trainings); safePush(StaffPaths.trainings);
} }
/// Pushes the leaderboard page (placeholder).
///
/// View performance rankings and achievements.
void toLeaderboard() { void toLeaderboard() {
pushNamed(StaffPaths.leaderboard); safePush(StaffPaths.leaderboard);
} }
/// Pushes the FAQs page.
///
/// Access frequently asked questions and help resources.
void toFaqs() { void toFaqs() {
pushNamed(StaffPaths.faqs); safePush(StaffPaths.faqs);
} }
// ==========================================================================
// PRIVACY & SECURITY
// ==========================================================================
/// Navigates to the privacy and security settings page.
///
/// Manage privacy preferences including:
/// * Location sharing settings
/// * View terms of service
/// * View privacy policy
void toPrivacySecurity() { void toPrivacySecurity() {
pushNamed(StaffPaths.privacySecurity); safePush(StaffPaths.privacySecurity);
} }
/// Navigates to the Terms of Service page.
///
/// Display the full terms of service document in a dedicated page view.
void toTermsOfService() { void toTermsOfService() {
pushNamed(StaffPaths.termsOfService); safePush(StaffPaths.termsOfService);
} }
/// Navigates to the Privacy Policy page.
///
/// Display the full privacy policy document in a dedicated page view.
void toPrivacyPolicy() { void toPrivacyPolicy() {
pushNamed(StaffPaths.privacyPolicy); safePush(StaffPaths.privacyPolicy);
} }
// ==========================================================================
// MESSAGING & COMMUNICATION
// ==========================================================================
/// Pushes the messages page (placeholder).
///
/// Access internal messaging system.
void toMessages() { void toMessages() {
pushNamed(StaffPaths.messages); safePush(StaffPaths.messages);
} }
/// Pushes the settings page (placeholder).
///
/// General app settings and preferences.
void toSettings() { void toSettings() {
pushNamed(StaffPaths.settings); safePush(StaffPaths.settings);
} }
} }

View File

@@ -57,6 +57,9 @@ class LocaleBloc extends Bloc<LocaleEvent, LocaleState> {
supportedLocales: state.supportedLocales, supportedLocales: state.supportedLocales,
), ),
); );
// 4. Reload from persistent storage to ensure synchronization
add(const LoadLocale());
} }
/// Handles the [LoadLocale] event by retrieving it via the use case and updating settings. /// Handles the [LoadLocale] event by retrieving it via the use case and updating settings.

View File

@@ -21,6 +21,13 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface {
@override @override
Future<Locale> getSavedLocale() async { Future<Locale> getSavedLocale() async {
final String? savedLanguageCode = await localDataSource.getLanguageCode();
if (savedLanguageCode != null) {
final Locale savedLocale = Locale(savedLanguageCode);
if (getSupportedLocales().contains(savedLocale)) {
return savedLocale;
}
}
return getDefaultLocale(); return getDefaultLocale();
} }

View File

@@ -349,6 +349,7 @@
"listening": "Listening...", "listening": "Listening...",
"send": "Send Message", "send": "Send Message",
"sending": "Sending...", "sending": "Sending...",
"transcribing": "Transcribing...",
"success_title": "Request Sent!", "success_title": "Request Sent!",
"success_message": "We're finding available workers for you right now. You'll be notified as they accept.", "success_message": "We're finding available workers for you right now. You'll be notified as they accept.",
"back_to_orders": "Back to Orders" "back_to_orders": "Back to Orders"
@@ -540,8 +541,8 @@
"min_break": "min break" "min_break": "min break"
}, },
"actions": { "actions": {
"approve_pay": "Approve & Process Payment", "approve_pay": "Approve",
"flag_review": "Flag for Review", "flag_review": "Review",
"download_pdf": "Download Invoice PDF" "download_pdf": "Download Invoice PDF"
}, },
"flag_dialog": { "flag_dialog": {
@@ -1317,7 +1318,7 @@
}, },
"find_shifts": { "find_shifts": {
"incomplete_profile_banner_title": "Your account isn't complete yet.", "incomplete_profile_banner_title": "Your account isn't complete yet.",
"incomplete_profile_banner_message": "You won't be able to apply for shifts until your account is fully set up. Complete your account now to unlock shift applications and start getting matched with opportunities.", "incomplete_profile_banner_message": "Complete your account now to unlock shift applications and start getting matched with opportunities.",
"incomplete_profile_cta": "Complete your account now", "incomplete_profile_cta": "Complete your account now",
"search_hint": "Search jobs, location...", "search_hint": "Search jobs, location...",
"filter_all": "All Jobs", "filter_all": "All Jobs",

View File

@@ -349,6 +349,7 @@
"listening": "Escuchando...", "listening": "Escuchando...",
"send": "Enviar Mensaje", "send": "Enviar Mensaje",
"sending": "Enviando...", "sending": "Enviando...",
"transcribing": "Transcribiendo...",
"success_title": "\u00a1Solicitud Enviada!", "success_title": "\u00a1Solicitud Enviada!",
"success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.", "success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.",
"back_to_orders": "Volver a \u00d3rdenes" "back_to_orders": "Volver a \u00d3rdenes"
@@ -535,8 +536,8 @@
"min_break": "min de descanso" "min_break": "min de descanso"
}, },
"actions": { "actions": {
"approve_pay": "Aprobar y Procesar Pago", "approve_pay": "Aprobar",
"flag_review": "Marcar para Revisi\u00f3n", "flag_review": "Revisi\u00f3n",
"download_pdf": "Descargar PDF de Factura" "download_pdf": "Descargar PDF de Factura"
}, },
"flag_dialog": { "flag_dialog": {
@@ -1312,7 +1313,7 @@
}, },
"find_shifts": { "find_shifts": {
"incomplete_profile_banner_title": "Tu cuenta aún no está completa.", "incomplete_profile_banner_title": "Tu cuenta aún no está completa.",
"incomplete_profile_banner_message": "No podrás solicitar turnos hasta que tu cuenta esté completamente configurada. Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.", "incomplete_profile_banner_message": "Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.",
"incomplete_profile_cta": "Completa tu cuenta ahora", "incomplete_profile_cta": "Completa tu cuenta ahora",
"search_hint": "Buscar trabajos, ubicaci\u00f3n...", "search_hint": "Buscar trabajos, ubicaci\u00f3n...",
"filter_all": "Todos", "filter_all": "Todos",

View File

@@ -6,16 +6,21 @@ import '../../domain/repositories/billing_connector_repository.dart';
/// Implementation of [BillingConnectorRepository]. /// Implementation of [BillingConnectorRepository].
class BillingConnectorRepositoryImpl implements BillingConnectorRepository { class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
BillingConnectorRepositoryImpl({ BillingConnectorRepositoryImpl({dc.DataConnectService? service})
dc.DataConnectService? service, : _service = service ?? dc.DataConnectService.instance;
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service; final dc.DataConnectService _service;
@override @override
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId}) async { Future<List<BusinessBankAccount>> getBankAccounts({
required String businessId,
}) async {
return _service.run(() async { return _service.run(() async {
final QueryResult<dc.GetAccountsByOwnerIdData, dc.GetAccountsByOwnerIdVariables> result = await _service.connector final QueryResult<
dc.GetAccountsByOwnerIdData,
dc.GetAccountsByOwnerIdVariables
>
result = await _service.connector
.getAccountsByOwnerId(ownerId: businessId) .getAccountsByOwnerId(ownerId: businessId)
.execute(); .execute();
@@ -26,21 +31,32 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
@override @override
Future<double> getCurrentBillAmount({required String businessId}) async { Future<double> getCurrentBillAmount({required String businessId}) async {
return _service.run(() async { return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId) .listInvoicesByBusinessId(businessId: businessId)
.execute(); .execute();
return result.data.invoices return result.data.invoices
.map(_mapInvoice) .map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open) .where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(0.0, (double sum, Invoice item) => sum + item.totalAmount); .fold<double>(
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
}); });
} }
@override @override
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async { Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
return _service.run(() async { return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId) .listInvoicesByBusinessId(businessId: businessId)
.limit(20) .limit(20)
.execute(); .execute();
@@ -55,14 +71,22 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
@override @override
Future<List<Invoice>> getPendingInvoices({required String businessId}) async { Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
return _service.run(() async { return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId) .listInvoicesByBusinessId(businessId: businessId)
.execute(); .execute();
return result.data.invoices return result.data.invoices
.map(_mapInvoice) .map(_mapInvoice)
.where((Invoice i) => .where(
i.status != InvoiceStatus.paid) (Invoice i) =>
i.status != InvoiceStatus.paid &&
i.status != InvoiceStatus.disputed &&
i.status != InvoiceStatus.open,
)
.toList(); .toList();
}); });
} }
@@ -79,16 +103,25 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
if (period == BillingPeriod.week) { if (period == BillingPeriod.week) {
final int daysFromMonday = now.weekday - DateTime.monday; final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(now.year, now.month, now.day) final DateTime monday = DateTime(
.subtract(Duration(days: daysFromMonday)); now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
start = monday; start = monday;
end = monday.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); end = monday.add(
const Duration(days: 6, hours: 23, minutes: 59, seconds: 59),
);
} else { } else {
start = DateTime(now.year, now.month, 1); start = DateTime(now.year, now.month, 1);
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59); end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
} }
final QueryResult<dc.ListShiftRolesByBusinessAndDatesSummaryData, dc.ListShiftRolesByBusinessAndDatesSummaryVariables> result = await _service.connector final QueryResult<
dc.ListShiftRolesByBusinessAndDatesSummaryData,
dc.ListShiftRolesByBusinessAndDatesSummaryVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary( .listShiftRolesByBusinessAndDatesSummary(
businessId: businessId, businessId: businessId,
start: _service.toTimestamp(start), start: _service.toTimestamp(start),
@@ -96,11 +129,13 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
) )
.execute(); .execute();
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles> shiftRoles = result.data.shiftRoles; final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) return <InvoiceItem>[]; if (shiftRoles.isEmpty) return <InvoiceItem>[];
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{}; final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role in shiftRoles) { for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId; final String roleId = role.roleId;
final String roleName = role.role.name; final String roleName = role.role.name;
final double hours = role.hours ?? 0.0; final double hours = role.hours ?? 0.0;
@@ -123,14 +158,16 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
} }
return summary.values return summary.values
.map((_RoleSummary item) => InvoiceItem( .map(
(_RoleSummary item) => InvoiceItem(
id: item.roleId, id: item.roleId,
invoiceId: item.roleId, invoiceId: item.roleId,
staffId: item.roleName, staffId: item.roleName,
workHours: item.totalHours, workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue, amount: item.totalValue,
)) ),
)
.toList(); .toList();
}); });
} }
@@ -146,7 +183,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
} }
@override @override
Future<void> disputeInvoice({required String id, required String reason}) async { Future<void> disputeInvoice({
required String id,
required String reason,
}) async {
return _service.run(() async { return _service.run(() async {
await _service.connector await _service.connector
.updateInvoice(id: id) .updateInvoice(id: id)
@@ -159,23 +199,39 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
// --- MAPPERS --- // --- MAPPERS ---
Invoice _mapInvoice(dynamic invoice) { Invoice _mapInvoice(dynamic invoice) {
final List<dynamic> rolesData = invoice.roles is List ? invoice.roles : []; List<InvoiceWorker> workers = <InvoiceWorker>[];
final List<InvoiceWorker> workers = rolesData.map((dynamic r) {
// Try to get workers from denormalized 'roles' field first
final List<dynamic> rolesData = invoice.roles is List
? invoice.roles
: <dynamic>[];
if (rolesData.isNotEmpty) {
workers = rolesData.map((dynamic r) {
final Map<String, dynamic> role = r as Map<String, dynamic>; final Map<String, dynamic> role = r as Map<String, dynamic>;
// Handle various possible key naming conventions in the JSON data // Handle various possible key naming conventions in the JSON data
final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown'; final String name =
final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff'; role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final double amount = (role['amount'] as num?)?.toDouble() ?? final String roleTitle =
(role['totalValue'] as num?)?.toDouble() ?? 0.0; role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
final double hours = (role['hours'] as num?)?.toDouble() ?? final double amount =
(role['amount'] as num?)?.toDouble() ??
(role['totalValue'] as num?)?.toDouble() ??
0.0;
final double hours =
(role['hours'] as num?)?.toDouble() ??
(role['workHours'] as num?)?.toDouble() ?? (role['workHours'] as num?)?.toDouble() ??
(role['totalHours'] as num?)?.toDouble() ?? 0.0; (role['totalHours'] as num?)?.toDouble() ??
final double rate = (role['rate'] as num?)?.toDouble() ?? 0.0;
(role['hourlyRate'] as num?)?.toDouble() ?? 0.0; final double rate =
(role['rate'] as num?)?.toDouble() ??
(role['hourlyRate'] as num?)?.toDouble() ??
0.0;
final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time']; final dynamic checkInVal =
final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time']; role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
final dynamic checkOutVal =
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
return InvoiceWorker( return InvoiceWorker(
name: name, name: name,
@@ -186,9 +242,57 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
checkIn: _service.toDateTime(checkInVal), checkIn: _service.toDateTime(checkInVal),
checkOut: _service.toDateTime(checkOutVal), checkOut: _service.toDateTime(checkOutVal),
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0, breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'], avatarUrl:
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
); );
}).toList(); }).toList();
}
// Fallback: If roles is empty, try to get workers from shift applications
else if (invoice.shift != null &&
invoice.shift.applications_on_shift != null) {
final List<dynamic> apps = invoice.shift.applications_on_shift;
workers = apps.map((dynamic app) {
final String name = app.staff?.fullName ?? 'Unknown';
final String roleTitle = app.shiftRole?.role?.name ?? 'Staff';
final double amount =
(app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0;
final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0;
// Calculate rate if not explicitly provided
double rate = 0.0;
if (hours > 0) {
rate = amount / hours;
}
// Map break type to minutes
int breakMin = 0;
final String? breakType = app.shiftRole?.breakType?.toString();
if (breakType != null) {
if (breakType.contains('10'))
breakMin = 10;
else if (breakType.contains('15'))
breakMin = 15;
else if (breakType.contains('30'))
breakMin = 30;
else if (breakType.contains('45'))
breakMin = 45;
else if (breakType.contains('60'))
breakMin = 60;
}
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(app.checkInTime),
checkOut: _service.toDateTime(app.checkOutTime),
breakMinutes: breakMin,
avatarUrl: app.staff?.photoUrl,
);
}).toList();
}
return Invoice( return Invoice(
id: invoice.id, id: invoice.id,
@@ -202,8 +306,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
issueDate: _service.toDateTime(invoice.issueDate)!, issueDate: _service.toDateTime(invoice.issueDate)!,
title: invoice.order?.eventName, title: invoice.order?.eventName,
clientName: invoice.business?.businessName, clientName: invoice.business?.businessName,
locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address, locationAddress:
staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0), invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
staffCount:
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
totalHours: _calculateTotalHours(rolesData), totalHours: _calculateTotalHours(rolesData),
workers: workers, workers: workers,
); );
@@ -256,10 +362,7 @@ class _RoleSummary {
final double totalHours; final double totalHours;
final double totalValue; final double totalValue;
_RoleSummary copyWith({ _RoleSummary copyWith({double? totalHours, double? totalValue}) {
double? totalHours,
double? totalValue,
}) {
return _RoleSummary( return _RoleSummary(
roleId: roleId, roleId: roleId,
roleName: roleName, roleName: roleName,
@@ -268,4 +371,3 @@ class _RoleSummary {
); );
} }
} }

View File

@@ -205,13 +205,23 @@ mixin SessionHandlerMixin {
try { try {
_emitSessionState(SessionState.loading()); _emitSessionState(SessionState.loading());
// Validate role if allowed roles are specified // Validate role only when allowed roles are specified.
if (_allowedRoles.isNotEmpty) { if (_allowedRoles.isNotEmpty) {
final bool isAuthorized = await validateUserRole( final String? userRole = await fetchUserRole(user.uid);
user.uid,
_allowedRoles, if (userRole == null) {
); // User has no record in the database yet. This is expected during
if (!isAuthorized) { // the sign-up flow: Firebase Auth fires authStateChanges before the
// repository has created the PostgreSQL user record. Do NOT sign out —
// just emit unauthenticated and let the registration flow complete.
_emitSessionState(SessionState.unauthenticated());
return;
}
if (!_allowedRoles.contains(userRole)) {
// User IS in the database but has a role that is not permitted in
// this app (e.g., a STAFF-only user trying to use the Client app).
// Sign them out to force them to use the correct app.
await auth.signOut(); await auth.signOut();
_emitSessionState(SessionState.unauthenticated()); _emitSessionState(SessionState.unauthenticated());
return; return;

View File

@@ -12,3 +12,5 @@ export 'src/widgets/ui_button.dart';
export 'src/widgets/ui_chip.dart'; export 'src/widgets/ui_chip.dart';
export 'src/widgets/ui_loading_page.dart'; export 'src/widgets/ui_loading_page.dart';
export 'src/widgets/ui_snackbar.dart'; export 'src/widgets/ui_snackbar.dart';
export 'src/widgets/ui_notice_banner.dart';
export 'src/widgets/ui_empty_state.dart';

View File

@@ -288,4 +288,7 @@ class UiIcons {
/// Microphone icon /// Microphone icon
static const IconData microphone = _IconLib.mic; static const IconData microphone = _IconLib.mic;
/// Language icon
static const IconData language = _IconLib.languages;
} }

View File

@@ -131,6 +131,15 @@ class UiTypography {
color: UiColors.textPrimary, color: UiColors.textPrimary,
); );
/// Title 1 Bold - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826)
/// Used for section headers and important labels.
static final TextStyle title1b = _primaryBase.copyWith(
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.5,
color: UiColors.textPrimary,
);
/// Title 2 Bold - Font: Instrument Sans, Size: 20, Height: 1.1 (#121826) /// Title 2 Bold - Font: Instrument Sans, Size: 20, Height: 1.1 (#121826)
static final TextStyle title2b = _primaryBase.copyWith( static final TextStyle title2b = _primaryBase.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -264,6 +273,16 @@ class UiTypography {
color: UiColors.textPrimary, color: UiColors.textPrimary,
); );
/// Title Uppercase 2 Bold - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.7 (#121826)
/// Used for section headers and important labels.
static final TextStyle titleUppercase2b = _primaryBase.copyWith(
fontWeight: FontWeight.w700,
fontSize: 14,
height: 1.5,
letterSpacing: 0.4,
color: UiColors.textPrimary,
);
/// Title Uppercase 3 Medium - Font: Instrument Sans, Size: 12, Height: 1.5, Spacing: 1.5 (#121826) /// Title Uppercase 3 Medium - Font: Instrument Sans, Size: 12, Height: 1.5, Spacing: 1.5 (#121826)
static final TextStyle titleUppercase3m = _primaryBase.copyWith( static final TextStyle titleUppercase3m = _primaryBase.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -3,7 +3,6 @@ import '../ui_constants.dart';
/// A custom button widget with different variants and icon support. /// A custom button widget with different variants and icon support.
class UiButton extends StatelessWidget { class UiButton extends StatelessWidget {
/// Creates a [UiButton] with a custom button builder. /// Creates a [UiButton] with a custom button builder.
const UiButton({ const UiButton({
super.key, super.key,
@@ -17,6 +16,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20, this.iconSize = 20,
this.size = UiButtonSize.large, this.size = UiButtonSize.large,
this.fullWidth = false, this.fullWidth = false,
this.isLoading = false,
}) : assert( }) : assert(
text != null || child != null, text != null || child != null,
'Either text or child must be provided', 'Either text or child must be provided',
@@ -34,6 +34,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20, this.iconSize = 20,
this.size = UiButtonSize.large, this.size = UiButtonSize.large,
this.fullWidth = false, this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _elevatedButtonBuilder, }) : buttonBuilder = _elevatedButtonBuilder,
assert( assert(
text != null || child != null, text != null || child != null,
@@ -52,6 +53,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20, this.iconSize = 20,
this.size = UiButtonSize.large, this.size = UiButtonSize.large,
this.fullWidth = false, this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _outlinedButtonBuilder, }) : buttonBuilder = _outlinedButtonBuilder,
assert( assert(
text != null || child != null, text != null || child != null,
@@ -70,6 +72,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20, this.iconSize = 20,
this.size = UiButtonSize.large, this.size = UiButtonSize.large,
this.fullWidth = false, this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _textButtonBuilder, }) : buttonBuilder = _textButtonBuilder,
assert( assert(
text != null || child != null, text != null || child != null,
@@ -88,11 +91,13 @@ class UiButton extends StatelessWidget {
this.iconSize = 20, this.iconSize = 20,
this.size = UiButtonSize.large, this.size = UiButtonSize.large,
this.fullWidth = false, this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _textButtonBuilder, }) : buttonBuilder = _textButtonBuilder,
assert( assert(
text != null || child != null, text != null || child != null,
'Either text or child must be provided', 'Either text or child must be provided',
); );
/// The text to display on the button. /// The text to display on the button.
final String? text; final String? text;
@@ -129,6 +134,9 @@ class UiButton extends StatelessWidget {
) )
buttonBuilder; buttonBuilder;
/// Whether to show a loading indicator.
final bool isLoading;
@override @override
/// Builds the button UI. /// Builds the button UI.
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -138,9 +146,9 @@ class UiButton extends StatelessWidget {
final Widget button = buttonBuilder( final Widget button = buttonBuilder(
context, context,
onPressed, isLoading ? null : onPressed,
mergedStyle, mergedStyle,
_buildButtonContent(), isLoading ? _buildLoadingContent() : _buildButtonContent(),
); );
if (fullWidth) { if (fullWidth) {
@@ -150,6 +158,15 @@ class UiButton extends StatelessWidget {
return button; return button;
} }
/// Builds the loading indicator.
Widget _buildLoadingContent() {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
/// Gets the style based on the button size. /// Gets the style based on the button size.
ButtonStyle _getSizeStyle() { ButtonStyle _getSizeStyle() {
switch (size) { switch (size) {

View File

@@ -0,0 +1,44 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
class UiEmptyState extends StatelessWidget {
const UiEmptyState({
super.key,
required this.icon,
required this.title,
required this.description,
this.iconColor,
});
final IconData icon;
final String title;
final String description;
final Color? iconColor;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 64, color: iconColor ?? UiColors.iconDisabled),
const SizedBox(height: UiConstants.space5),
Text(
title,
style: UiTypography.title1b.textDescription,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
child: Text(
description,
style: UiTypography.body2m.textDescription,
textAlign: TextAlign.center,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../ui_constants.dart';
/// A customizable notice banner widget for displaying informational messages.
///
/// [UiNoticeBanner] displays a message with an optional icon and supports
/// custom styling through title and description text.
class UiNoticeBanner extends StatelessWidget {
/// Creates a [UiNoticeBanner].
const UiNoticeBanner({
super.key,
this.icon,
required this.title,
this.description,
this.backgroundColor,
this.borderRadius,
this.padding,
});
/// The icon to display on the left side.
/// Defaults to null. The icon will be rendered with primary color and 24pt size.
final IconData? icon;
/// The title text to display.
final String title;
/// Optional description text to display below the title.
final String? description;
/// The background color of the banner.
/// Defaults to [UiColors.primary] with 8% opacity.
final Color? backgroundColor;
/// The border radius of the banner.
/// Defaults to [UiConstants.radiusLg].
final BorderRadius? borderRadius;
/// The padding around the banner content.
/// Defaults to [UiConstants.space4] on all sides.
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Container(
padding: padding ?? const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08),
borderRadius: borderRadius ?? UiConstants.radiusLg,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (icon != null) ...<Widget>[
Icon(icon, color: UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.body2m.textPrimary,
),
if (description != null) ...<Widget>[
const SizedBox(height: 2),
Text(
description!,
style: UiTypography.body2r.textSecondary,
),
],
],
),
),
],
),
);
}
}

View File

@@ -90,6 +90,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
); );
} }
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
// token before we fire any mutations. Without this, there is a race
// condition where the gRPC layer sends the request unauthenticated
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
await firebaseUser.getIdToken(true);
// New user created successfully, proceed to create PostgreSQL entities // New user created successfully, proceed to create PostgreSQL entities
return await _createBusinessAndUser( return await _createBusinessAndUser(
firebaseUser: firebaseUser, firebaseUser: firebaseUser,
@@ -165,6 +171,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
); );
} }
// Force-refresh the ID token so the Data Connect SDK receives a valid
// bearer token before any subsequent Data Connect queries run.
await firebaseUser.getIdToken(true);
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = await _checkBusinessUserExists( final bool hasBusinessAccount = await _checkBusinessUserExists(
firebaseUser.uid, firebaseUser.uid,
@@ -329,7 +339,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Future<void> signOut() async { Future<void> signOut() async {
try { try {
await _service.auth.signOut(); await _service.auth.signOut();
dc.ClientSessionStore.instance.clear();
_service.clearCache(); _service.clearCache();
} catch (e) { } catch (e) {
throw Exception('Error signing out: ${e.toString()}'); throw Exception('Error signing out: ${e.toString()}');

View File

@@ -12,6 +12,7 @@ import 'domain/usecases/get_spending_breakdown.dart';
import 'domain/usecases/approve_invoice.dart'; import 'domain/usecases/approve_invoice.dart';
import 'domain/usecases/dispute_invoice.dart'; import 'domain/usecases/dispute_invoice.dart';
import 'presentation/blocs/billing_bloc.dart'; import 'presentation/blocs/billing_bloc.dart';
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'presentation/models/billing_invoice_model.dart'; import 'presentation/models/billing_invoice_model.dart';
import 'presentation/pages/billing_page.dart'; import 'presentation/pages/billing_page.dart';
import 'presentation/pages/completion_review_page.dart'; import 'presentation/pages/completion_review_page.dart';
@@ -44,6 +45,10 @@ class BillingModule extends Module {
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(), getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(), getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(), getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
),
);
i.add<ShiftCompletionReviewBloc>(
() => ShiftCompletionReviewBloc(
approveInvoice: i.get<ApproveInvoiceUseCase>(), approveInvoice: i.get<ApproveInvoiceUseCase>(),
disputeInvoice: i.get<DisputeInvoiceUseCase>(), disputeInvoice: i.get<DisputeInvoiceUseCase>(),
), ),

View File

@@ -8,8 +8,6 @@ import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart'; import '../../domain/usecases/get_pending_invoices.dart';
import '../../domain/usecases/get_savings_amount.dart'; import '../../domain/usecases/get_savings_amount.dart';
import '../../domain/usecases/get_spending_breakdown.dart'; import '../../domain/usecases/get_spending_breakdown.dart';
import '../../domain/usecases/approve_invoice.dart';
import '../../domain/usecases/dispute_invoice.dart';
import '../models/billing_invoice_model.dart'; import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart'; import '../models/spending_breakdown_model.dart';
import 'billing_event.dart'; import 'billing_event.dart';
@@ -26,21 +24,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetPendingInvoicesUseCase getPendingInvoices, required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory, required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown, required GetSpendingBreakdownUseCase getSpendingBreakdown,
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _getBankAccounts = getBankAccounts, }) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount, _getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount, _getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices, _getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory, _getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown, _getSpendingBreakdown = getSpendingBreakdown,
_approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const BillingState()) { super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted); on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged); on<BillingPeriodChanged>(_onPeriodChanged);
on<BillingInvoiceApproved>(_onInvoiceApproved);
on<BillingInvoiceDisputed>(_onInvoiceDisputed);
} }
final GetBankAccountsUseCase _getBankAccounts; final GetBankAccountsUseCase _getBankAccounts;
@@ -49,8 +41,6 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetPendingInvoicesUseCase _getPendingInvoices; final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown; final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final ApproveInvoiceUseCase _approveInvoice;
final DisputeInvoiceUseCase _disputeInvoice;
Future<void> _onLoadStarted( Future<void> _onLoadStarted(
BillingLoadStarted event, BillingLoadStarted event,
@@ -78,10 +68,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
results[5] as List<BusinessBankAccount>; results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models // Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = final List<BillingInvoice> uiPendingInvoices = pendingInvoices
pendingInvoices.map(_mapInvoiceToUiModel).toList(); .map(_mapInvoiceToUiModel)
final List<BillingInvoice> uiInvoiceHistory = .toList();
invoiceHistory.map(_mapInvoiceToUiModel).toList(); final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown = final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems); _mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold( final double periodTotal = uiSpendingBreakdown.fold(
@@ -101,10 +93,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
), ),
); );
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) =>
status: BillingStatus.failure, state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
errorMessage: errorKey,
),
); );
} }
@@ -115,8 +105,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final List<InvoiceItem> spendingItems = final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
await _getSpendingBreakdown.call(event.period); .call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown = final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems); _mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold( final double periodTotal = uiSpendingBreakdown.fold(
@@ -131,46 +121,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
), ),
); );
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) =>
status: BillingStatus.failure, state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
errorMessage: errorKey,
),
);
}
Future<void> _onInvoiceApproved(
BillingInvoiceApproved event,
Emitter<BillingState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _approveInvoice.call(event.invoiceId);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onInvoiceDisputed(
BillingInvoiceDisputed event,
Emitter<BillingState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _disputeInvoice.call(
DisputeInvoiceParams(id: event.invoiceId, reason: event.reason),
);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
); );
} }
@@ -180,15 +132,18 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
? 'N/A' ? 'N/A'
: formatter.format(invoice.issueDate!); : formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((InvoiceWorker w) { final List<BillingWorkerRecord> workers = invoice.workers.map((
InvoiceWorker w,
) {
final DateFormat timeFormat = DateFormat('h:mm a');
return BillingWorkerRecord( return BillingWorkerRecord(
workerName: w.name, workerName: w.name,
roleName: w.role, roleName: w.role,
totalAmount: w.amount, totalAmount: w.amount,
hours: w.hours, hours: w.hours,
rate: w.rate, rate: w.rate,
startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--', startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--', endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes, breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl, workerAvatarUrl: w.avatarUrl,
); );
@@ -197,32 +152,34 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
String? overallStart; String? overallStart;
String? overallEnd; String? overallEnd;
// Find valid times from workers instead of just taking the first one // Find valid times from actual DateTime checks to ensure chronological sorting
final validStartTimes = workers final List<DateTime> validCheckIns = invoice.workers
.where((w) => w.startTime != '--:--') .where((InvoiceWorker w) => w.checkIn != null)
.map((w) => w.startTime) .map((InvoiceWorker w) => w.checkIn!)
.toList(); .toList();
final validEndTimes = workers final List<DateTime> validCheckOuts = invoice.workers
.where((w) => w.endTime != '--:--') .where((InvoiceWorker w) => w.checkOut != null)
.map((w) => w.endTime) .map((InvoiceWorker w) => w.checkOut!)
.toList(); .toList();
if (validStartTimes.isNotEmpty) { final DateFormat timeFormat = DateFormat('h:mm a');
validStartTimes.sort();
overallStart = validStartTimes.first; if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) { } else if (workers.isNotEmpty) {
overallStart = workers.first.startTime; overallStart = workers.first.startTime;
} }
if (validEndTimes.isNotEmpty) { if (validCheckOuts.isNotEmpty) {
validEndTimes.sort(); validCheckOuts.sort();
overallEnd = validEndTimes.last; overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) { } else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime; overallEnd = workers.first.endTime;
} }
return BillingInvoice( return BillingInvoice(
id: invoice.invoiceNumber ?? invoice.id, id: invoice.id,
title: invoice.title ?? 'N/A', title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote', locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A', clientName: invoice.clientName ?? 'N/A',

View File

@@ -24,20 +24,3 @@ class BillingPeriodChanged extends BillingEvent {
@override @override
List<Object?> get props => <Object?>[period]; List<Object?> get props => <Object?>[period];
} }
class BillingInvoiceApproved extends BillingEvent {
const BillingInvoiceApproved(this.invoiceId);
final String invoiceId;
@override
List<Object?> get props => <Object?>[invoiceId];
}
class BillingInvoiceDisputed extends BillingEvent {
const BillingInvoiceDisputed(this.invoiceId, this.reason);
final String invoiceId;
final String reason;
@override
List<Object?> get props => <Object?>[invoiceId, reason];
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/usecases/approve_invoice.dart';
import '../../../domain/usecases/dispute_invoice.dart';
import 'shift_completion_review_event.dart';
import 'shift_completion_review_state.dart';
class ShiftCompletionReviewBloc
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
with BlocErrorHandler<ShiftCompletionReviewState> {
ShiftCompletionReviewBloc({
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
on<ShiftCompletionReviewApproved>(_onApproved);
on<ShiftCompletionReviewDisputed>(_onDisputed);
}
final ApproveInvoiceUseCase _approveInvoice;
final DisputeInvoiceUseCase _disputeInvoice;
Future<void> _onApproved(
ShiftCompletionReviewApproved event,
Emitter<ShiftCompletionReviewState> emit,
) async {
emit(state.copyWith(status: ShiftCompletionReviewStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _approveInvoice.call(event.invoiceId);
emit(
state.copyWith(
status: ShiftCompletionReviewStatus.success,
message: 'approved',
),
);
},
onError: (String errorKey) => state.copyWith(
status: ShiftCompletionReviewStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onDisputed(
ShiftCompletionReviewDisputed event,
Emitter<ShiftCompletionReviewState> emit,
) async {
emit(state.copyWith(status: ShiftCompletionReviewStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _disputeInvoice.call(
DisputeInvoiceParams(id: event.invoiceId, reason: event.reason),
);
emit(
state.copyWith(
status: ShiftCompletionReviewStatus.success,
message: 'disputed',
),
);
},
onError: (String errorKey) => state.copyWith(
status: ShiftCompletionReviewStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
/// Base class for all shift completion review events.
abstract class ShiftCompletionReviewEvent extends Equatable {
/// Creates a [ShiftCompletionReviewEvent].
const ShiftCompletionReviewEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Event triggered when an invoice is approved.
class ShiftCompletionReviewApproved extends ShiftCompletionReviewEvent {
/// Creates a [ShiftCompletionReviewApproved] event.
const ShiftCompletionReviewApproved(this.invoiceId);
/// The ID of the invoice to approve.
final String invoiceId;
@override
List<Object?> get props => <Object?>[invoiceId];
}
/// Event triggered when an invoice is disputed.
class ShiftCompletionReviewDisputed extends ShiftCompletionReviewEvent {
/// Creates a [ShiftCompletionReviewDisputed] event.
const ShiftCompletionReviewDisputed(this.invoiceId, this.reason);
/// The ID of the invoice to dispute.
final String invoiceId;
/// The reason for the dispute.
final String reason;
@override
List<Object?> get props => <Object?>[invoiceId, reason];
}

View File

@@ -0,0 +1,51 @@
import 'package:equatable/equatable.dart';
/// Status of the shift completion review process.
enum ShiftCompletionReviewStatus {
/// Initial state.
initial,
/// Loading state.
loading,
/// Success state.
success,
/// Failure state.
failure,
}
/// State for the [ShiftCompletionReviewBloc].
class ShiftCompletionReviewState extends Equatable {
/// Creates a [ShiftCompletionReviewState].
const ShiftCompletionReviewState({
this.status = ShiftCompletionReviewStatus.initial,
this.message,
this.errorMessage,
});
/// Current status of the process.
final ShiftCompletionReviewStatus status;
/// Success message (e.g., 'approved' or 'disputed').
final String? message;
/// Error message to display if [status] is [ShiftCompletionReviewStatus.failure].
final String? errorMessage;
/// Creates a copy of this state with the given fields replaced.
ShiftCompletionReviewState copyWith({
ShiftCompletionReviewStatus? status,
String? message,
String? errorMessage,
}) {
return ShiftCompletionReviewState(
status: status ?? this.status,
message: message ?? this.message,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[status, message, errorMessage];
}

View File

@@ -11,7 +11,7 @@ class BillingInvoice extends Equatable {
required this.workersCount, required this.workersCount,
required this.totalHours, required this.totalHours,
required this.status, required this.status,
this.workers = const [], this.workers = const <BillingWorkerRecord>[],
this.startTime, this.startTime,
this.endTime, this.endTime,
}); });
@@ -70,7 +70,7 @@ class BillingWorkerRecord extends Equatable {
final String? workerAvatarUrl; final String? workerAvatarUrl;
@override @override
List<Object?> get props => [ List<Object?> get props => <Object?>[
workerName, workerName,
roleName, roleName,
totalAmount, totalAmount,

View File

@@ -9,7 +9,6 @@ import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart'; import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart'; import '../blocs/billing_state.dart';
import '../widgets/invoice_history_section.dart'; import '../widgets/invoice_history_section.dart';
import '../widgets/payment_method_card.dart';
import '../widgets/pending_invoices_section.dart'; import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart'; import '../widgets/spending_breakdown_card.dart';
@@ -97,7 +96,7 @@ class _BillingViewState extends State<BillingView> {
leading: Center( leading: Center(
child: UiIconButton( child: UiIconButton(
icon: UiIcons.arrowLeft, icon: UiIcons.arrowLeft,
backgroundColor: UiColors.white.withOpacity(0.15), backgroundColor: UiColors.white.withValues(alpha: 0.15),
iconColor: UiColors.white, iconColor: UiColors.white,
useBlur: true, useBlur: true,
size: 40, size: 40,
@@ -106,21 +105,21 @@ class _BillingViewState extends State<BillingView> {
), ),
title: Text( title: Text(
t.client_billing.title, t.client_billing.title,
style: UiTypography.headline3b.copyWith(color: UiColors.white), style: UiTypography.headline3b.copyWith(
color: UiColors.white,
),
), ),
centerTitle: false, centerTitle: false,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Padding( background: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(bottom: UiConstants.space8),
bottom: UiConstants.space8,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[ children: <Widget>[
Text( Text(
t.client_billing.current_period, t.client_billing.current_period,
style: UiTypography.footnote2r.copyWith( style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withOpacity(0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
@@ -224,156 +223,13 @@ class _BillingViewState extends State<BillingView> {
if (state.pendingInvoices.isNotEmpty) ...<Widget>[ if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices), PendingInvoicesSection(invoices: state.pendingInvoices),
], ],
const PaymentMethodCard(), // const PaymentMethodCard(),
const SpendingBreakdownCard(), const SpendingBreakdownCard(),
_buildSavingsCard(state.savings),
if (state.invoiceHistory.isNotEmpty) if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory), InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space16),
_buildExportButton(),
const SizedBox(height: UiConstants.space12),
],
),
);
}
Widget _buildSavingsCard(double amount) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.accent.withOpacity(0.5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(UiIcons.trendingDown, size: 18, color: UiColors.accentForeground),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_billing.rate_optimization_title,
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: UiTypography.footnote2r.textSecondary,
children: [
TextSpan(text: t.client_billing.rate_optimization_save),
TextSpan(
text: t.client_billing.rate_optimization_amount(amount: amount.toStringAsFixed(0)),
style: UiTypography.footnote2b.textPrimary,
),
TextSpan(text: t.client_billing.rate_optimization_shifts),
],
),
),
const SizedBox(height: UiConstants.space3),
SizedBox(
height: 32,
child: UiButton.primary(
text: t.client_billing.view_details,
onPressed: () {},
size: UiButtonSize.small,
),
),
],
),
),
],
),
);
}
Widget _buildExportButton() {
return SizedBox(
width: double.infinity,
child: UiButton.secondary(
text: t.client_billing.export_button,
leadingIcon: UiIcons.download,
onPressed: () {},
size: UiButtonSize.large,
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: UiConstants.space12),
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgPopup,
shape: BoxShape.circle,
border: Border.all(color: UiColors.border),
),
child: const Icon(
UiIcons.file,
size: 48,
color: UiColors.textSecondary,
),
),
const SizedBox(height: UiConstants.space4),
Text(
t.client_billing.no_invoices_period,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
], ],
), ),
); );
} }
} }
class _InvoicesReadyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => Modular.to.toInvoiceReady(),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.success.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.success.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(UiIcons.file, color: UiColors.success),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_billing.invoices_ready_title,
style: UiTypography.body1b.copyWith(color: UiColors.success),
),
Text(
t.client_billing.invoices_ready_subtitle,
style: UiTypography.footnote2r.copyWith(color: UiColors.success),
),
],
),
),
const Icon(UiIcons.chevronRight, color: UiColors.success),
],
),
),
);
}
}

View File

@@ -1,12 +1,14 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../models/billing_invoice_model.dart'; import '../models/billing_invoice_model.dart';
import '../widgets/completion_review/completion_review_actions.dart';
import '../widgets/completion_review/completion_review_amount.dart';
import '../widgets/completion_review/completion_review_info.dart';
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
import '../widgets/completion_review/completion_review_worker_card.dart';
import '../widgets/completion_review/completion_review_workers_header.dart';
class ShiftCompletionReviewPage extends StatefulWidget { class ShiftCompletionReviewPage extends StatefulWidget {
const ShiftCompletionReviewPage({this.invoice, super.key}); const ShiftCompletionReviewPage({this.invoice, super.key});
@@ -14,7 +16,8 @@ class ShiftCompletionReviewPage extends StatefulWidget {
final BillingInvoice? invoice; final BillingInvoice? invoice;
@override @override
State<ShiftCompletionReviewPage> createState() => _ShiftCompletionReviewPageState(); State<ShiftCompletionReviewPage> createState() =>
_ShiftCompletionReviewPageState();
} }
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> { class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
@@ -26,395 +29,65 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
void initState() { void initState() {
super.initState(); super.initState();
// Use widget.invoice if provided, else try to get from arguments // Use widget.invoice if provided, else try to get from arguments
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice; invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((BillingWorkerRecord w) { final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
BillingWorkerRecord w,
) {
if (searchQuery.isEmpty) return true; if (searchQuery.isEmpty) return true;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) || return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase()); w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList(); }).toList();
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF8FAFC), appBar: UiAppBar(
title: invoice.title,
subtitle: invoice.clientName,
showBackButton: true,
),
body: SafeArea( body: SafeArea(
child: Column(
children: <Widget>[
_buildHeader(context),
Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildInvoiceInfoCard(), CompletionReviewInfo(invoice: invoice),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildAmountCard(), CompletionReviewAmount(invoice: invoice),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
_buildWorkersHeader(), // CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
const SizedBox(height: UiConstants.space4), // const SizedBox(height: UiConstants.space4),
_buildSearchAndTabs(), // CompletionReviewSearchAndTabs(
const SizedBox(height: UiConstants.space4), // selectedTab: selectedTab,
...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)), // workersCount: invoice.workersCount,
const SizedBox(height: UiConstants.space6), // onTabChanged: (int index) =>
_buildActionButtons(context), // setState(() => selectedTab = index),
const SizedBox(height: UiConstants.space4), // onSearchChanged: (String val) =>
_buildDownloadLink(), // setState(() => searchQuery = val),
const SizedBox(height: UiConstants.space8), // ),
// const SizedBox(height: UiConstants.space4),
// ...filteredWorkers.map(
// (BillingWorkerRecord worker) =>
// CompletionReviewWorkerCard(worker: worker),
// ),
// const SizedBox(height: UiConstants.space4),
], ],
), ),
), ),
), ),
], bottomNavigationBar: Container(
), padding: const EdgeInsets.all(UiConstants.space5),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: UiColors.border)),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: UiColors.border,
borderRadius: UiConstants.radiusFull,
),
),
const SizedBox(height: UiConstants.space4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(t.client_billing.invoice_ready, style: UiTypography.headline4b.textPrimary),
Text(t.client_billing.review_and_approve_subtitle, style: UiTypography.body2r.textSecondary),
],
),
UiIconButton.secondary(
icon: UiIcons.close,
onTap: () => Navigator.of(context).pop(),
),
],
),
],
),
);
}
Widget _buildInvoiceInfoCard() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
Text(invoice.clientName, style: UiTypography.body2r.textSecondary),
const SizedBox(height: UiConstants.space4),
_buildInfoRow(UiIcons.calendar, invoice.date),
const SizedBox(height: UiConstants.space2),
_buildInfoRow(UiIcons.clock, '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}'),
const SizedBox(height: UiConstants.space2),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
],
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Row(
children: <Widget>[
Icon(icon, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space3),
Text(text, style: UiTypography.body2r.textSecondary),
],
);
}
Widget _buildAmountCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: const Color(0xFFDBEAFE)),
),
child: Column(
children: <Widget>[
Text(
t.client_billing.total_amount_label,
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
),
const SizedBox(height: UiConstants.space2),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
const SizedBox(height: UiConstants.space1),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);
}
Widget _buildWorkersHeader() {
return Row(
children: <Widget>[
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.workers_tab.title(count: invoice.workersCount),
style: UiTypography.title2b.textPrimary,
),
],
);
}
Widget _buildSearchAndTabs() {
return Column(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: UiConstants.radiusMd,
),
child: TextField(
onChanged: (String val) => setState(() => searchQuery = val),
decoration: InputDecoration(
icon: const Icon(UiIcons.search, size: 18, color: UiColors.iconSecondary),
hintText: t.client_billing.workers_tab.search_hint,
hintStyle: UiTypography.body2r.textSecondary,
border: InputBorder.none,
),
),
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Expanded(
child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1),
),
],
),
],
);
}
Widget _buildTabButton(String text, int index) {
final bool isSelected = selectedTab == index;
return GestureDetector(
onTap: () => setState(() => selectedTab = index),
child: Container(
height: 40,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border),
),
child: Center(
child: Text(
text,
style: UiTypography.body2b.copyWith(
color: isSelected ? Colors.white : UiColors.textSecondary,
),
),
),
),
);
}
Widget _buildWorkerCard(BillingWorkerRecord worker) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: UiConstants.radiusLg, border: Border(
border: Border.all(color: UiColors.border.withOpacity(0.5)), top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null ? NetworkImage(worker.workerAvatarUrl!) : null,
child: worker.workerAvatarUrl == null ? const Icon(UiIcons.user, size: 20, color: UiColors.iconSecondary) : null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(worker.workerName, style: UiTypography.body1b.textPrimary),
Text(worker.roleName, style: UiTypography.footnote2r.textSecondary),
],
), ),
), ),
Column( child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text('\$${worker.totalAmount.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary),
Text('${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', style: UiTypography.footnote2r.textSecondary),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text('${worker.startTime} - ${worker.endTime}', style: UiTypography.footnote2b.textPrimary),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: [
const Icon(UiIcons.coffee, size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
Text('${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', style: UiTypography.footnote2r.textSecondary),
],
),
),
const Spacer(),
UiIconButton.secondary(
icon: UiIcons.edit,
onTap: () {},
),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(
icon: UiIcons.warning,
onTap: () {},
),
],
),
],
),
);
}
Widget _buildActionButtons(BuildContext context) {
return Column(
children: <Widget>[
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: UiIcons.checkCircle,
onPressed: () {
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoice.id));
Modular.to.pop();
UiSnackbar.show(context, message: t.client_billing.approved_success, type: UiSnackbarType.success);
},
size: UiButtonSize.large,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
),
),
),
const SizedBox(height: UiConstants.space3),
SizedBox(
width: double.infinity,
child: Container(
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: Colors.orange, width: 2),
),
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: () => _showFlagDialog(context),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange,
side: BorderSide.none,
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
),
),
),
),
],
);
}
Widget _buildDownloadLink() {
return Center(
child: TextButton.icon(
onPressed: () {},
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
label: Text(
t.client_billing.actions.download_pdf,
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
),
),
);
}
void _showFlagDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.client_billing.flag_dialog.title),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: t.client_billing.flag_dialog.hint,
),
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
Modular.get<BillingBloc>().add(
BillingInvoiceDisputed(invoice.id, controller.text),
);
Navigator.pop(dialogContext);
Modular.to.pop();
UiSnackbar.show(context, message: t.client_billing.flagged_success, type: UiSnackbarType.warning);
}
},
child: Text(t.client_billing.flag_dialog.button),
),
],
), ),
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/billing_bloc.dart'; import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart'; import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart'; import '../blocs/billing_state.dart';
@@ -25,15 +26,9 @@ class InvoiceReadyView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true),
title: const Text('Invoices Ready'),
leading: UiIconButton.secondary(
icon: UiIcons.arrowLeft,
onTap: () => Modular.to.pop(),
),
),
body: BlocBuilder<BillingBloc, BillingState>( body: BlocBuilder<BillingBloc, BillingState>(
builder: (context, state) { builder: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.loading) { if (state.status == BillingStatus.loading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -42,8 +37,12 @@ class InvoiceReadyView extends StatelessWidget {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: <Widget>[
const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary), const Icon(
UiIcons.file,
size: 64,
color: UiColors.iconSecondary,
),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'No invoices ready yet', 'No invoices ready yet',
@@ -57,9 +56,10 @@ class InvoiceReadyView extends StatelessWidget {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
itemCount: state.invoiceHistory.length, itemCount: state.invoiceHistory.length,
separatorBuilder: (context, index) => const SizedBox(height: 16), separatorBuilder: (BuildContext context, int index) =>
itemBuilder: (context, index) { const SizedBox(height: 16),
final invoice = state.invoiceHistory[index]; itemBuilder: (BuildContext context, int index) {
final BillingInvoice invoice = state.invoiceHistory[index];
return _InvoiceSummaryCard(invoice: invoice); return _InvoiceSummaryCard(invoice: invoice);
}, },
); );
@@ -81,7 +81,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border),
boxShadow: [ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: UiColors.black.withValues(alpha: 0.04), color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 10, blurRadius: 10,
@@ -91,40 +91,51 @@ class _InvoiceSummaryCard extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.success.withValues(alpha: 0.1), color: UiColors.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
'READY', 'READY',
style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success), style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.success,
), ),
), ),
Text(
invoice.date,
style: UiTypography.footnote2r.textTertiary,
), ),
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(invoice.title, style: UiTypography.title2b.textPrimary), Text(invoice.title, style: UiTypography.title2b.textPrimary),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary), Text(
invoice.locationAddress,
style: UiTypography.body2r.textSecondary,
),
const Divider(height: 32), const Divider(height: 32),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: <Widget>[
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary), Text(
Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary), 'TOTAL AMOUNT',
style: UiTypography.titleUppercase4m.textSecondary,
),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.title2b.primary,
),
], ],
), ),
UiButton.primary( UiButton.primary(

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart'; import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart'; import '../blocs/billing_state.dart';
@@ -20,6 +21,7 @@ class PendingInvoicesPage extends StatelessWidget {
appBar: UiAppBar( appBar: UiAppBar(
title: t.client_billing.awaiting_approval, title: t.client_billing.awaiting_approval,
showBackButton: true, showBackButton: true,
onLeadingPressed: () => Modular.to.toClientBilling(),
), ),
body: _buildBody(context, state), body: _buildBody(context, state),
); );

View File

@@ -1,85 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
class ClientTimesheetsPage extends StatelessWidget {
const ClientTimesheetsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.t.client_billing.timesheets.title),
elevation: 0,
backgroundColor: UiColors.white,
foregroundColor: UiColors.primary,
),
body: ListView.separated(
padding: const EdgeInsets.all(UiConstants.space5),
itemCount: 3,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final workers = ['Sarah Miller', 'David Chen', 'Mike Ross'];
final roles = ['Cashier', 'Stocker', 'Event Support'];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.separatorPrimary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(workers[index], style: UiTypography.body2b.textPrimary),
Text('\$84.00', style: UiTypography.body2b.primary),
],
),
Text(roles[index], style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: 12),
Row(
children: [
const Icon(UiIcons.clock, size: 14, color: UiColors.iconSecondary),
const SizedBox(width: 6),
Text('09:00 AM - 05:00 PM (8h)', style: UiTypography.footnote2r.textSecondary),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: UiButton.secondary(
text: context.t.client_billing.timesheets.decline_button,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(color: UiColors.destructive),
),
onPressed: () {},
),
),
const SizedBox(width: 12),
Expanded(
child: UiButton.primary(
text: context.t.client_billing.timesheets.approve_button,
onPressed: () {
UiSnackbar.show(
context,
message: context.t.client_billing.timesheets.approved_message,
type: UiSnackbarType.success,
);
},
),
),
],
),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,123 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
import '../../blocs/billing_bloc.dart';
import '../../blocs/billing_event.dart';
class CompletionReviewActions extends StatelessWidget {
const CompletionReviewActions({required this.invoiceId, super.key});
final String invoiceId;
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftCompletionReviewBloc>.value(
value: Modular.get<ShiftCompletionReviewBloc>(),
child:
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
),
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
);
}
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,
builder: (BuildContext dialogContext) => AlertDialog(
title: Text(t.client_billing.flag_dialog.title),
surfaceTintColor: Colors.white,
backgroundColor: Colors.white,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: t.client_billing.flag_dialog.hint,
),
maxLines: 3,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
BlocProvider.of<ShiftCompletionReviewBloc>(context).add(
ShiftCompletionReviewDisputed(invoiceId, controller.text),
);
Navigator.pop(dialogContext);
}
},
child: Text(t.client_billing.flag_dialog.button),
),
],
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
class CompletionReviewAmount extends StatelessWidget {
const CompletionReviewAmount({required this.invoice, super.key});
final BillingInvoice invoice;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
children: <Widget>[
Text(
t.client_billing.total_amount_label,
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
class CompletionReviewInfo extends StatelessWidget {
const CompletionReviewInfo({required this.invoice, super.key});
final BillingInvoice invoice;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: <Widget>[
_buildInfoRow(UiIcons.calendar, invoice.date),
_buildInfoRow(
UiIcons.clock,
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
],
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Row(
children: <Widget>[
Icon(icon, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space3),
Text(text, style: UiTypography.body2r.textSecondary),
],
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
class CompletionReviewSearchAndTabs extends StatelessWidget {
const CompletionReviewSearchAndTabs({
required this.selectedTab,
required this.onTabChanged,
required this.onSearchChanged,
required this.workersCount,
super.key,
});
final int selectedTab;
final ValueChanged<int> onTabChanged;
final ValueChanged<String> onSearchChanged;
final int workersCount;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: UiConstants.radiusMd,
),
child: TextField(
onChanged: onSearchChanged,
decoration: InputDecoration(
icon: const Icon(
UiIcons.search,
size: 18,
color: UiColors.iconSecondary,
),
hintText: t.client_billing.workers_tab.search_hint,
hintStyle: UiTypography.body2r.textSecondary,
border: InputBorder.none,
),
),
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Expanded(
child: _buildTabButton(
t.client_billing.workers_tab.needs_review(count: 0),
0,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTabButton(
t.client_billing.workers_tab.all(count: workersCount),
1,
),
),
],
),
],
);
}
Widget _buildTabButton(String text, int index) {
final bool isSelected = selectedTab == index;
return GestureDetector(
onTap: () => onTabChanged(index),
child: Container(
height: 40,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
),
),
child: Center(
child: Text(
text,
style: UiTypography.body2b.copyWith(
color: isSelected ? Colors.white : UiColors.textSecondary,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
class CompletionReviewWorkerCard extends StatelessWidget {
const CompletionReviewWorkerCard({required this.worker, super.key});
final BillingWorkerRecord worker;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null
? NetworkImage(worker.workerAvatarUrl!)
: null,
child: worker.workerAvatarUrl == null
? const Icon(
UiIcons.user,
size: 20,
color: UiColors.iconSecondary,
)
: null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.workerName,
style: UiTypography.body1b.textPrimary,
),
Text(
worker.roleName,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${worker.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary,
),
Text(
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text(
'${worker.startTime} - ${worker.endTime}',
style: UiTypography.footnote2b.textPrimary,
),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.coffee,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
const Spacer(),
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
class CompletionReviewWorkersHeader extends StatelessWidget {
const CompletionReviewWorkersHeader({required this.workersCount, super.key});
final int workersCount;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.workers_tab.title(count: workersCount),
style: UiTypography.title2b.textPrimary,
),
],
);
}
}

View File

@@ -14,40 +14,18 @@ class InvoiceHistorySection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: <Widget>[ crossAxisAlignment: CrossAxisAlignment.start,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(
t.client_billing.invoice_history, t.client_billing.invoice_history,
style: UiTypography.title2b.textPrimary, style: UiTypography.title2b.textPrimary,
), ),
TextButton(
onPressed: () {},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
children: [
Text(
t.client_billing.view_all,
style: UiTypography.body2b.copyWith(color: UiColors.primary),
),
const SizedBox(width: 4),
const Icon(UiIcons.chevronRight, size: 16, color: UiColors.primary),
],
),
),
],
),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)), border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: UiColors.black.withValues(alpha: 0.04), color: UiColors.black.withValues(alpha: 0.04),
@@ -99,7 +77,7 @@ class _InvoiceItem extends StatelessWidget {
), ),
child: Icon( child: Icon(
UiIcons.file, UiIcons.file,
color: UiColors.iconSecondary.withOpacity(0.6), color: UiColors.iconSecondary.withValues(alpha: 0.6),
size: 20, size: 20,
), ),
), ),
@@ -108,10 +86,7 @@ class _InvoiceItem extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(invoice.title, style: UiTypography.body1r.textPrimary),
invoice.id,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
Text( Text(
invoice.date, invoice.date,
style: UiTypography.footnote2r.textSecondary, style: UiTypography.footnote2r.textSecondary,
@@ -129,12 +104,6 @@ class _InvoiceItem extends StatelessWidget {
_StatusBadge(status: invoice.status), _StatusBadge(status: invoice.status),
], ],
), ),
const SizedBox(width: UiConstants.space4),
Icon(
UiIcons.download,
size: 20,
color: UiColors.iconSecondary.withOpacity(0.3),
),
], ],
), ),
); );

View File

@@ -21,18 +21,11 @@ class PendingInvoicesSection extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () => Modular.to.toAwaitingApproval(), onTap: () => Modular.to.toAwaitingApproval(),
child: Container( child: Container(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)), border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
@@ -48,9 +41,9 @@ class PendingInvoicesSection extends StatelessWidget {
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
Row( Row(
children: [ children: <Widget>[
Text( Text(
t.client_billing.awaiting_approval, t.client_billing.awaiting_approval,
style: UiTypography.body1b.textPrimary, style: UiTypography.body1b.textPrimary,
@@ -86,7 +79,7 @@ class PendingInvoicesSection extends StatelessWidget {
Icon( Icon(
UiIcons.chevronRight, UiIcons.chevronRight,
size: 20, size: 20,
color: UiColors.iconSecondary.withOpacity(0.5), color: UiColors.iconSecondary.withValues(alpha: 0.5),
), ),
], ],
), ),
@@ -115,6 +108,8 @@ class PendingInvoiceCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
const SizedBox(height: UiConstants.space3),
Row( Row(
children: <Widget>[ children: <Widget>[
const Icon( const Icon(
@@ -134,8 +129,6 @@ class PendingInvoiceCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
const SizedBox(height: UiConstants.space1),
Row( Row(
children: <Widget>[ children: <Widget>[
Text( Text(
@@ -187,7 +180,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container( Container(
width: 1, width: 1,
height: 32, height: 32,
color: UiColors.border.withOpacity(0.3), color: UiColors.border.withValues(alpha: 0.3),
), ),
Expanded( Expanded(
child: _buildStatItem( child: _buildStatItem(
@@ -199,7 +192,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container( Container(
width: 1, width: 1,
height: 32, height: 32,
color: UiColors.border.withOpacity(0.3), color: UiColors.border.withValues(alpha: 0.3),
), ),
Expanded( Expanded(
child: _buildStatItem( child: _buildStatItem(
@@ -232,7 +225,11 @@ class PendingInvoiceCard extends StatelessWidget {
Widget _buildStatItem(IconData icon, String value, String label) { Widget _buildStatItem(IconData icon, String value, String label) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)), Icon(
icon,
size: 20,
color: UiColors.iconSecondary.withValues(alpha: 0.8),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
value, value,

View File

@@ -9,12 +9,16 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
_onRouteChanged(); _onRouteChanged();
} }
static const List<String> _hideBottomBarPaths = <String>[
ClientPaths.completionReview,
ClientPaths.awaitingApproval,
];
void _onRouteChanged() { void _onRouteChanged() {
final String path = Modular.to.path; final String path = Modular.to.path;
int newIndex = state.currentIndex; int newIndex = state.currentIndex;
// Detect which tab is active based on the route path // Detect which tab is active based on the route path
// Using contains() to handle child routes and trailing slashes
if (path.contains(ClientPaths.coverage)) { if (path.contains(ClientPaths.coverage)) {
newIndex = 0; newIndex = 0;
} else if (path.contains(ClientPaths.billing)) { } else if (path.contains(ClientPaths.billing)) {
@@ -27,8 +31,13 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
newIndex = 4; newIndex = 4;
} }
if (newIndex != state.currentIndex) { final bool showBottomBar = !_hideBottomBarPaths.any(path.contains);
emit(state.copyWith(currentIndex: newIndex));
if (newIndex != state.currentIndex ||
showBottomBar != state.showBottomBar) {
emit(
state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar),
);
} }
} }
@@ -37,19 +46,19 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
switch (index) { switch (index) {
case 0: case 0:
Modular.to.navigate(ClientPaths.coverage); Modular.to.toClientCoverage();
break; break;
case 1: case 1:
Modular.to.navigate(ClientPaths.billing); Modular.to.toClientBilling();
break; break;
case 2: case 2:
Modular.to.navigate(ClientPaths.home); Modular.to.toClientHome();
break; break;
case 3: case 3:
Modular.to.navigate(ClientPaths.orders); Modular.to.toClientOrders();
break; break;
case 4: case 4:
Modular.to.navigate(ClientPaths.reports); Modular.to.toClientReports();
break; break;
} }
// State update will happen via _onRouteChanged // State update will happen via _onRouteChanged

View File

@@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart';
class ClientMainState extends Equatable { class ClientMainState extends Equatable {
const ClientMainState({ const ClientMainState({
this.currentIndex = 2, // Default to Home this.currentIndex = 2, // Default to Home
this.showBottomBar = true,
}); });
final int currentIndex; final int currentIndex;
final bool showBottomBar;
ClientMainState copyWith({int? currentIndex}) { ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
return ClientMainState(currentIndex: currentIndex ?? this.currentIndex); return ClientMainState(
currentIndex: currentIndex ?? this.currentIndex,
showBottomBar: showBottomBar ?? this.showBottomBar,
);
} }
@override @override
List<Object> get props => <Object>[currentIndex]; List<Object> get props => <Object>[currentIndex, showBottomBar];
} }

View File

@@ -24,6 +24,8 @@ class ClientMainPage extends StatelessWidget {
body: const RouterOutlet(), body: const RouterOutlet(),
bottomNavigationBar: BlocBuilder<ClientMainCubit, ClientMainState>( bottomNavigationBar: BlocBuilder<ClientMainCubit, ClientMainState>(
builder: (BuildContext context, ClientMainState state) { builder: (BuildContext context, ClientMainState state) {
if (!state.showBottomBar) return const SizedBox.shrink();
return ClientMainBottomBar( return ClientMainBottomBar(
currentIndex: state.currentIndex, currentIndex: state.currentIndex,
onTap: (int index) { onTap: (int index) {

View File

@@ -99,16 +99,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override @override
Future<UserSessionData> getUserSessionData() async { Future<UserSessionData> getUserSessionData() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? business = session?.business;
if (business != null) {
return UserSessionData(
businessName: business.businessName,
photoUrl: business.companyLogoUrl,
);
}
return await _service.run(() async { return await _service.run(() async {
final String businessId = await _service.getBusinessId(); final String businessId = await _service.getBusinessId();
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables> final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>

View File

@@ -25,7 +25,7 @@ class ActionsWidget extends StatelessWidget {
title: i18n.rapid, title: i18n.rapid,
subtitle: i18n.rapid_subtitle, subtitle: i18n.rapid_subtitle,
icon: UiIcons.zap, icon: UiIcons.zap,
color: UiColors.tagError, color: UiColors.tagError.withValues(alpha: 0.5),
borderColor: UiColors.borderError.withValues(alpha: 0.3), borderColor: UiColors.borderError.withValues(alpha: 0.3),
iconBgColor: UiColors.white, iconBgColor: UiColors.white,
iconColor: UiColors.textError, iconColor: UiColors.textError,

View File

@@ -26,15 +26,16 @@ import 'presentation/pages/recurring_order_page.dart';
/// presentation layer BLoCs. /// presentation layer BLoCs.
class ClientCreateOrderModule extends Module { class ClientCreateOrderModule extends Module {
@override @override
List<Module> get imports => <Module>[DataConnectModule()]; List<Module> get imports => <Module>[DataConnectModule(), CoreModule()];
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>( i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
(Injector i) => ClientCreateOrderRepositoryImpl( () => ClientCreateOrderRepositoryImpl(
service: i.get<dc.DataConnectService>(), service: i.get<dc.DataConnectService>(),
rapidOrderService: i.get<RapidOrderService>(), rapidOrderService: i.get<RapidOrderService>(),
fileUploadService: i.get<FileUploadService>(),
), ),
); );
@@ -49,7 +50,7 @@ class ClientCreateOrderModule extends Module {
// BLoCs // BLoCs
i.add<RapidOrderBloc>( i.add<RapidOrderBloc>(
(Injector i) => RapidOrderBloc( () => RapidOrderBloc(
i.get<TranscribeRapidOrderUseCase>(), i.get<TranscribeRapidOrderUseCase>(),
i.get<ParseRapidOrderTextToOrderUseCase>(), i.get<ParseRapidOrderTextToOrderUseCase>(),
i.get<AudioRecorderService>(), i.get<AudioRecorderService>(),

View File

@@ -17,11 +17,14 @@ class ClientCreateOrderRepositoryImpl
ClientCreateOrderRepositoryImpl({ ClientCreateOrderRepositoryImpl({
required dc.DataConnectService service, required dc.DataConnectService service,
required RapidOrderService rapidOrderService, required RapidOrderService rapidOrderService,
required FileUploadService fileUploadService,
}) : _service = service, }) : _service = service,
_rapidOrderService = rapidOrderService; _rapidOrderService = rapidOrderService,
_fileUploadService = fileUploadService;
final dc.DataConnectService _service; final dc.DataConnectService _service;
final RapidOrderService _rapidOrderService; final RapidOrderService _rapidOrderService;
final FileUploadService _fileUploadService;
@override @override
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async { Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
@@ -379,29 +382,82 @@ class ClientCreateOrderRepositoryImpl
); );
final RapidOrderParsedData data = response.parsed; final RapidOrderParsedData data = response.parsed;
// Fetch Business ID
final String businessId = await _service.getBusinessId();
// 1. Hub Matching
final OperationResult<
dc.ListTeamHubsByOwnerIdData,
dc.ListTeamHubsByOwnerIdVariables
>
hubResult = await _service.connector
.listTeamHubsByOwnerId(ownerId: businessId)
.execute();
final List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs = hubResult.data.teamHubs;
final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub(
hubs,
data.locationHint,
);
// 2. Roles Matching
// We fetch vendors to get the first one as a context for role matching.
final OperationResult<dc.ListVendorsData, void> vendorResult =
await _service.connector.listVendors().execute();
final List<dc.ListVendorsVendors> vendors = vendorResult.data.vendors;
String? selectedVendorId;
List<dc.ListRolesByVendorIdRoles> availableRoles =
<dc.ListRolesByVendorIdRoles>[];
if (vendors.isNotEmpty) {
selectedVendorId = vendors.first.id;
final OperationResult<
dc.ListRolesByVendorIdData,
dc.ListRolesByVendorIdVariables
>
roleResult = await _service.connector
.listRolesByVendorId(vendorId: selectedVendorId)
.execute();
availableRoles = roleResult.data.roles;
}
final DateTime startAt = final DateTime startAt =
DateTime.tryParse(data.startAt ?? '') ?? DateTime.now(); DateTime.tryParse(data.startAt ?? '') ?? DateTime.now();
final DateTime endAt = final DateTime endAt =
DateTime.tryParse(data.endAt ?? '') ?? DateTime.tryParse(data.endAt ?? '') ??
startAt.add(const Duration(hours: 8)); startAt.add(const Duration(hours: 8));
final String startTimeStr = DateFormat('hh:mm a').format(startAt); final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal());
final String endTimeStr = DateFormat('hh:mm a').format(endAt); final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal());
return domain.OneTimeOrder( return domain.OneTimeOrder(
date: startAt, date: startAt,
location: data.locationHint ?? '', location: bestHub?.hubName ?? data.locationHint ?? '',
eventName: data.notes ?? '', eventName: data.notes ?? '',
hub: data.locationHint != null vendorId: selectedVendorId,
hub: bestHub != null
? domain.OneTimeOrderHubDetails( ? domain.OneTimeOrderHubDetails(
id: '', id: bestHub.id,
name: data.locationHint!, name: bestHub.hubName,
address: '', address: bestHub.address,
placeId: bestHub.placeId,
latitude: bestHub.latitude ?? 0,
longitude: bestHub.longitude ?? 0,
city: bestHub.city,
state: bestHub.state,
street: bestHub.street,
country: bestHub.country,
zipCode: bestHub.zipCode,
) )
: null, : null,
positions: data.positions.map((RapidOrderPosition p) { positions: data.positions.map((RapidOrderPosition p) {
final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole(
availableRoles,
p.role,
);
return domain.OneTimeOrderPosition( return domain.OneTimeOrderPosition(
role: p.role, role: matchedRole?.id ?? p.role,
count: p.count, count: p.count,
startTime: startTimeStr, startTime: startTimeStr,
endTime: endTimeStr, endTime: endTimeStr,
@@ -412,8 +468,18 @@ class ClientCreateOrderRepositoryImpl
@override @override
Future<String> transcribeRapidOrder(String audioPath) async { Future<String> transcribeRapidOrder(String audioPath) async {
// 1. Upload the audio file first
final String fileName = audioPath.split('/').last;
final FileUploadResponse uploadResponse = await _fileUploadService
.uploadFile(
filePath: audioPath,
fileName: fileName,
category: 'rapid-order-audio',
);
// 2. Transcribe using the remote URI
final RapidOrderTranscriptionResponse response = await _rapidOrderService final RapidOrderTranscriptionResponse response = await _rapidOrderService
.transcribeAudio(audioFileUri: audioPath); .transcribeAudio(audioFileUri: uploadResponse.fileUri);
return response.transcript; return response.transcript;
} }
@@ -643,4 +709,85 @@ class ClientCreateOrderRepositoryImpl
} }
return domain.OrderType.oneTime; return domain.OrderType.oneTime;
} }
dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub(
List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs,
String? hint,
) {
if (hint == null || hint.isEmpty || hubs.isEmpty) return null;
final String normalizedHint = hint.toLowerCase();
dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch;
double highestScore = -1;
for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) {
final String name = hub.hubName.toLowerCase();
final String address = hub.address.toLowerCase();
double score = 0;
if (name == normalizedHint || address == normalizedHint) {
score = 100;
} else if (name.contains(normalizedHint) ||
address.contains(normalizedHint)) {
score = 80;
} else if (normalizedHint.contains(name) ||
normalizedHint.contains(address)) {
score = 60;
} else {
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
final List<String> hubWords = ('$name $address').split(RegExp(r'\s+'));
int overlap = 0;
for (final String word in hintWords) {
if (word.length > 2 && hubWords.contains(word)) overlap++;
}
score = overlap * 10.0;
}
if (score > highestScore) {
highestScore = score;
bestMatch = hub;
}
}
return (highestScore >= 10) ? bestMatch : null;
}
dc.ListRolesByVendorIdRoles? _findBestRole(
List<dc.ListRolesByVendorIdRoles> roles,
String? hint,
) {
if (hint == null || hint.isEmpty || roles.isEmpty) return null;
final String normalizedHint = hint.toLowerCase();
dc.ListRolesByVendorIdRoles? bestMatch;
double highestScore = -1;
for (final dc.ListRolesByVendorIdRoles role in roles) {
final String name = role.name.toLowerCase();
double score = 0;
if (name == normalizedHint) {
score = 100;
} else if (name.contains(normalizedHint)) {
score = 80;
} else if (normalizedHint.contains(name)) {
score = 60;
} else {
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
final List<String> roleWords = name.split(RegExp(r'\s+'));
int overlap = 0;
for (final String word in hintWords) {
if (word.length > 2 && roleWords.contains(word)) overlap++;
}
score = overlap * 10.0;
}
if (score > highestScore) {
highestScore = score;
bestMatch = role;
}
}
return (highestScore >= 10) ? bestMatch : null;
}
} }

View File

@@ -15,7 +15,7 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
this._parseRapidOrderUseCase, this._parseRapidOrderUseCase,
this._audioRecorderService, this._audioRecorderService,
) : super( ) : super(
const RapidOrderInitial( const RapidOrderState(
examples: <String>[ examples: <String>[
'"We had a call out. Need 2 cooks ASAP"', '"We had a call out. Need 2 cooks ASAP"',
'"Need 5 bartenders ASAP until 5am"', '"Need 5 bartenders ASAP until 5am"',
@@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderMessageChanged event, RapidOrderMessageChanged event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) { ) {
if (state is RapidOrderInitial) { emit(
emit((state as RapidOrderInitial).copyWith(message: event.message)); state.copyWith(message: event.message, status: RapidOrderStatus.initial),
} );
} }
Future<void> _onVoiceToggled( Future<void> _onVoiceToggled(
RapidOrderVoiceToggled event, RapidOrderVoiceToggled event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) async { ) async {
if (state is RapidOrderInitial) { if (!state.isListening) {
final RapidOrderInitial currentState = state as RapidOrderInitial; // Start Recording
final bool newListeningState = !currentState.isListening; await handleError(
emit: emit.call,
emit(currentState.copyWith(isListening: newListeningState)); action: () async {
await _audioRecorderService.startRecording();
// Simulate voice recognition
if (newListeningState) {
await Future<void>.delayed(const Duration(seconds: 2));
if (state is RapidOrderInitial) {
emit( emit(
(state as RapidOrderInitial).copyWith( state.copyWith(isListening: true, status: RapidOrderStatus.initial),
message: 'Need 2 servers for a banquet right now.', );
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
);
} else {
// Stop Recording and Transcribe
await handleError(
emit: emit.call,
action: () async {
// 1. Stop recording
final String? audioPath = await _audioRecorderService.stopRecording();
if (audioPath == null) {
emit(
state.copyWith(
isListening: false, isListening: false,
status: RapidOrderStatus.initial,
), ),
); );
return;
} }
}
// 2. Transcribe
emit(
state.copyWith(
isListening: false,
isTranscribing: true,
status: RapidOrderStatus.initial,
),
);
final String transcription = await _transcribeRapidOrderUseCase(
audioPath,
);
// 3. Update message
emit(
state.copyWith(
message: transcription,
isListening: false,
isTranscribing: false,
status: RapidOrderStatus.initial,
),
);
},
onError: (String errorKey) => state.copyWith(
status: RapidOrderStatus.failure,
error: errorKey,
isListening: false,
isTranscribing: false,
),
);
} }
} }
@@ -70,30 +113,29 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderSubmitted event, RapidOrderSubmitted event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) async { ) async {
final RapidOrderState currentState = state; final String message = state.message;
if (currentState is RapidOrderInitial) { emit(state.copyWith(status: RapidOrderStatus.submitting));
final String message = currentState.message;
emit(const RapidOrderSubmitting());
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message); final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(RapidOrderParsed(order)); emit(
}, state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
onError: (String errorKey) => RapidOrderFailure(errorKey), );
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
); );
}
} }
void _onExampleSelected( void _onExampleSelected(
RapidOrderExampleSelected event, RapidOrderExampleSelected event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) { ) {
if (state is RapidOrderInitial) {
final String cleanedExample = event.example.replaceAll('"', ''); final String cleanedExample = event.example.replaceAll('"', '');
emit((state as RapidOrderInitial).copyWith(message: cleanedExample)); emit(
} state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
);
} }
} }

View File

@@ -1,59 +1,55 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
abstract class RapidOrderState extends Equatable { enum RapidOrderStatus { initial, submitting, parsed, failure }
const RapidOrderState();
@override class RapidOrderState extends Equatable {
List<Object?> get props => <Object?>[]; const RapidOrderState({
} this.status = RapidOrderStatus.initial,
class RapidOrderInitial extends RapidOrderState {
const RapidOrderInitial({
this.message = '', this.message = '',
this.isListening = false, this.isListening = false,
required this.examples, this.isTranscribing = false,
this.examples = const <String>[],
this.error,
this.parsedOrder,
}); });
final RapidOrderStatus status;
final String message; final String message;
final bool isListening; final bool isListening;
final bool isTranscribing;
final List<String> examples; final List<String> examples;
final String? error;
final OneTimeOrder? parsedOrder;
@override @override
List<Object?> get props => <Object?>[message, isListening, examples]; List<Object?> get props => <Object?>[
status,
message,
isListening,
isTranscribing,
examples,
error,
parsedOrder,
];
RapidOrderInitial copyWith({ RapidOrderState copyWith({
RapidOrderStatus? status,
String? message, String? message,
bool? isListening, bool? isListening,
bool? isTranscribing,
List<String>? examples, List<String>? examples,
String? error,
OneTimeOrder? parsedOrder,
}) { }) {
return RapidOrderInitial( return RapidOrderState(
status: status ?? this.status,
message: message ?? this.message, message: message ?? this.message,
isListening: isListening ?? this.isListening, isListening: isListening ?? this.isListening,
isTranscribing: isTranscribing ?? this.isTranscribing,
examples: examples ?? this.examples, examples: examples ?? this.examples,
error: error ?? this.error,
parsedOrder: parsedOrder ?? this.parsedOrder,
); );
} }
} }
class RapidOrderSubmitting extends RapidOrderState {
const RapidOrderSubmitting();
}
class RapidOrderSuccess extends RapidOrderState {
const RapidOrderSuccess();
}
class RapidOrderFailure extends RapidOrderState {
const RapidOrderFailure(this.error);
final String error;
@override
List<Object?> get props => <Object?>[error];
}
class RapidOrderParsed extends RapidOrderState {
const RapidOrderParsed(this.order);
final OneTimeOrder order;
@override
List<Object?> get props => <Object?>[order];
}

View File

@@ -53,7 +53,8 @@ class OneTimeOrderPage extends StatelessWidget {
: null, : null,
hubManagers: state.managers.map(_mapManager).toList(), hubManagers: state.managers.map(_mapManager).toList(),
isValid: state.isValid, isValid: state.isValid,
title: state.isRapidDraft ? 'Rapid Order : Verify the order' : null, title: state.isRapidDraft ? 'Rapid Order' : null,
subtitle: state.isRapidDraft ? 'Verify the order details' : null,
onEventNameChanged: (String val) => onEventNameChanged: (String val) =>
bloc.add(OneTimeOrderEventNameChanged(val)), bloc.add(OneTimeOrderEventNameChanged(val)),
onVendorChanged: (Vendor val) => onVendorChanged: (Vendor val) =>

View File

@@ -12,18 +12,16 @@ class UiOrderType {
/// Order type constants for the create order feature /// Order type constants for the create order feature
const List<UiOrderType> orderTypes = <UiOrderType>[ const List<UiOrderType> orderTypes = <UiOrderType>[
/// TODO: FEATURE_NOT_YET_IMPLEMENTED UiOrderType(
// UiOrderType( id: 'rapid',
// id: 'rapid', titleKey: 'client_create_order.types.rapid',
// titleKey: 'client_create_order.types.rapid', descriptionKey: 'client_create_order.types.rapid_desc',
// descriptionKey: 'client_create_order.types.rapid_desc', ),
// ),
UiOrderType( UiOrderType(
id: 'one-time', id: 'one-time',
titleKey: 'client_create_order.types.one_time', titleKey: 'client_create_order.types.one_time',
descriptionKey: 'client_create_order.types.one_time_desc', descriptionKey: 'client_create_order.types.one_time_desc',
), ),
UiOrderType( UiOrderType(
id: 'recurring', id: 'recurring',
titleKey: 'client_create_order.types.recurring', titleKey: 'client_create_order.types.recurring',

View File

@@ -10,7 +10,6 @@ import '../../blocs/rapid_order/rapid_order_event.dart';
import '../../blocs/rapid_order/rapid_order_state.dart'; import '../../blocs/rapid_order/rapid_order_state.dart';
import 'rapid_order_example_card.dart'; import 'rapid_order_example_card.dart';
import 'rapid_order_header.dart'; import 'rapid_order_header.dart';
import 'rapid_order_success_view.dart';
/// The main content of the Rapid Order page. /// The main content of the Rapid Order page.
class RapidOrderView extends StatelessWidget { class RapidOrderView extends StatelessWidget {
@@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientCreateOrderRapidEn labels =
t.client_create_order.rapid;
return BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderSuccess) {
return RapidOrderSuccessView(
title: labels.success_title,
message: labels.success_message,
buttonLabel: labels.back_to_orders,
onDone: () => Modular.to.toClientOrders(),
);
}
return const _RapidOrderForm(); return const _RapidOrderForm();
},
);
} }
} }
@@ -65,24 +48,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
return BlocListener<RapidOrderBloc, RapidOrderState>( return BlocListener<RapidOrderBloc, RapidOrderState>(
listener: (BuildContext context, RapidOrderState state) { listener: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderInitial) { if (state.status == RapidOrderStatus.initial) {
if (_messageController.text != state.message) { if (_messageController.text != state.message) {
_messageController.text = state.message; _messageController.text = state.message;
_messageController.selection = TextSelection.fromPosition( _messageController.selection = TextSelection.fromPosition(
TextPosition(offset: _messageController.text.length), TextPosition(offset: _messageController.text.length),
); );
} }
} else if (state is RapidOrderParsed) { } else if (state.status == RapidOrderStatus.parsed &&
state.parsedOrder != null) {
Modular.to.toCreateOrderOneTime( Modular.to.toCreateOrderOneTime(
arguments: <String, dynamic>{ arguments: <String, dynamic>{
'order': state.order, 'order': state.parsedOrder,
'isRapidDraft': true, 'isRapidDraft': true,
}, },
); );
} else if (state is RapidOrderFailure) { } else if (state.status == RapidOrderStatus.failure &&
state.error != null) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.error), message: translateErrorKey(state.error!),
type: UiSnackbarType.error, type: UiSnackbarType.error,
); );
} }
@@ -95,68 +80,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
subtitle: labels.subtitle, subtitle: labels.subtitle,
date: dateStr, date: dateStr,
time: timeStr, time: timeStr,
onBack: () => Modular.to.navigate(ClientPaths.createOrder), onBack: () => Modular.to.toCreateOrder(),
), ),
// Content // Content
Expanded(
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final bool isSubmitting =
state.status == RapidOrderStatus.submitting;
return Column(
children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
labels.tell_us,
style: UiTypography.headline3m.textPrimary,
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.destructive,
borderRadius: UiConstants.radiusSm,
),
child: Text(
labels.urgent_badge,
style: UiTypography.footnote2b.copyWith(
color: UiColors.white,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Main Card
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final RapidOrderInitial? initialState =
state is RapidOrderInitial ? state : null;
final bool isSubmitting =
state is RapidOrderSubmitting;
return Column(
children: <Widget>[ children: <Widget>[
// Icon // Icon
const _AnimatedZapIcon(), const _AnimatedZapIcon(),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
labels.need_staff, labels.need_staff,
style: UiTypography.headline2m.textPrimary, style: UiTypography.headline3b.textPrimary,
), ),
const SizedBox(height: UiConstants.space2),
Text( Text(
labels.type_or_speak, labels.type_or_speak,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -165,8 +112,7 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Examples // Examples
if (initialState != null) ...state.examples.asMap().entries.map((
...initialState.examples.asMap().entries.map((
MapEntry<int, String> entry, MapEntry<int, String> entry,
) { ) {
final int index = entry.key; final int index = entry.key;
@@ -203,16 +149,19 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
}, },
hintText: labels.hint, hintText: labels.hint,
), ),
const SizedBox(height: UiConstants.space4), ],
),
// Actions ),
_RapidOrderActions( ),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: _RapidOrderActions(
labels: labels, labels: labels,
isSubmitting: isSubmitting, isSubmitting: isSubmitting,
isListening: initialState?.isListening ?? false, isListening: state.isListening,
isMessageEmpty: isTranscribing: state.isTranscribing,
initialState != null && isMessageEmpty: state.message.trim().isEmpty,
initialState.message.trim().isEmpty, ),
), ),
], ],
); );
@@ -222,10 +171,6 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
], ],
), ),
), ),
),
],
),
),
); );
} }
} }
@@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget {
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.destructive.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: const Icon(UiIcons.zap, color: UiColors.white, size: 32), child: const Icon(UiIcons.zap, color: UiColors.white, size: 32),
); );
@@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget {
required this.labels, required this.labels,
required this.isSubmitting, required this.isSubmitting,
required this.isListening, required this.isListening,
required this.isTranscribing,
required this.isMessageEmpty, required this.isMessageEmpty,
}); });
final TranslationsClientCreateOrderRapidEn labels; final TranslationsClientCreateOrderRapidEn labels;
final bool isSubmitting; final bool isSubmitting;
final bool isListening; final bool isListening;
final bool isTranscribing;
final bool isMessageEmpty; final bool isMessageEmpty;
@override @override
@@ -279,9 +219,15 @@ class _RapidOrderActions extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: UiButton.secondary( child: UiButton.secondary(
text: isListening ? labels.listening : labels.speak, text: isTranscribing
? labels.transcribing
: isListening
? labels.listening
: labels.speak,
leadingIcon: UiIcons.microphone, leadingIcon: UiIcons.microphone,
onPressed: () => BlocProvider.of<RapidOrderBloc>( onPressed: isTranscribing
? null
: () => BlocProvider.of<RapidOrderBloc>(
context, context,
).add(const RapidOrderVoiceToggled()), ).add(const RapidOrderVoiceToggled()),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(

View File

@@ -39,6 +39,7 @@ class OneTimeOrderView extends StatelessWidget {
required this.onDone, required this.onDone,
required this.onBack, required this.onBack,
this.title, this.title,
this.subtitle,
super.key, super.key,
}); });
@@ -56,6 +57,7 @@ class OneTimeOrderView extends StatelessWidget {
final OrderManagerUiModel? selectedHubManager; final OrderManagerUiModel? selectedHubManager;
final bool isValid; final bool isValid;
final String? title; final String? title;
final String? subtitle;
final ValueChanged<String> onEventNameChanged; final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged; final ValueChanged<Vendor> onVendorChanged;
@@ -102,7 +104,7 @@ class OneTimeOrderView extends StatelessWidget {
children: <Widget>[ children: <Widget>[
OneTimeOrderHeader( OneTimeOrderHeader(
title: title ?? labels.title, title: title ?? labels.title,
subtitle: labels.subtitle, subtitle: subtitle ?? labels.subtitle,
onBack: onBack, onBack: onBack,
), ),
Expanded( Expanded(
@@ -140,7 +142,7 @@ class OneTimeOrderView extends StatelessWidget {
children: <Widget>[ children: <Widget>[
OneTimeOrderHeader( OneTimeOrderHeader(
title: title ?? labels.title, title: title ?? labels.title,
subtitle: labels.subtitle, subtitle: subtitle ?? labels.subtitle,
onBack: onBack, onBack: onBack,
), ),
Expanded( Expanded(

View File

@@ -2,13 +2,13 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// A quick report card widget for navigating to specific reports. /// A quick report card widget for navigating to specific reports.
/// ///
/// Displays an icon, name, and a quick navigation to a report page. /// Displays an icon, name, and a quick navigation to a report page.
/// Used in the quick reports grid of the reports page. /// Used in the quick reports grid of the reports page.
class ReportCard extends StatelessWidget { class ReportCard extends StatelessWidget {
const ReportCard({ const ReportCard({
super.key, super.key,
required this.icon, required this.icon,
@@ -17,6 +17,7 @@ class ReportCard extends StatelessWidget {
required this.iconColor, required this.iconColor,
required this.route, required this.route,
}); });
/// The icon to display for this report. /// The icon to display for this report.
final IconData icon; final IconData icon;
@@ -35,7 +36,7 @@ class ReportCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => Modular.to.pushNamed(route), onTap: () => Modular.to.safePush(route),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -86,8 +87,7 @@ class ReportCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
context.t.client_reports.quick_reports context.t.client_reports.quick_reports.two_click_export,
.two_click_export,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: UiColors.textSecondary, color: UiColors.textSecondary,

View File

@@ -17,9 +17,9 @@ import '../widgets/phone_verification_page/phone_input.dart';
/// This page coordinates the authentication flow by switching between /// This page coordinates the authentication flow by switching between
/// [PhoneInput] and [OtpVerification] based on the current [AuthState]. /// [PhoneInput] and [OtpVerification] based on the current [AuthState].
class PhoneVerificationPage extends StatefulWidget { class PhoneVerificationPage extends StatefulWidget {
/// Creates a [PhoneVerificationPage]. /// Creates a [PhoneVerificationPage].
const PhoneVerificationPage({super.key, required this.mode}); const PhoneVerificationPage({super.key, required this.mode});
/// The authentication mode (login or signup). /// The authentication mode (login or signup).
final AuthMode mode; final AuthMode mode;
@@ -123,10 +123,10 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
); );
Future<void>.delayed(const Duration(seconds: 5), () { Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return; if (!mounted) return;
Modular.to.navigate('/'); Modular.to.toInitialPage();
}); });
} else if (messageKey == 'errors.auth.unauthorized_app') { } else if (messageKey == 'errors.auth.unauthorized_app') {
Modular.to.pop(); Modular.to.popSafe();
} }
} }
}, },

View File

@@ -33,7 +33,9 @@ class _ClockInPageState extends State<ClockInPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return BlocProvider<ClockInBloc>.value( return BlocProvider<ClockInBloc>.value(
value: _bloc, value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>( child: BlocConsumer<ClockInBloc, ClockInState>(
@@ -60,22 +62,17 @@ class _ClockInPageState extends State<ClockInPage> {
final String? activeShiftId = state.attendance.activeShiftId; final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected = final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId; selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime = final DateTime? checkInTime = isActiveSelected
isActiveSelected ? state.attendance.checkInTime : null; ? state.attendance.checkInTime
final DateTime? checkOutTime = : null;
isActiveSelected ? state.attendance.checkOutTime : null; final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn = final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected; state.attendance.isCheckedIn && isActiveSelected;
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(title: i18n.title, showBackButton: false),
titleWidget: Text(
i18n.title,
style: UiTypography.title1m.textPrimary,
),
showBackButton: false,
centerTitle: false,
),
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
@@ -92,18 +89,18 @@ class _ClockInPageState extends State<ClockInPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
// Commute Tracker (shows before date selector when applicable) // // Commute Tracker (shows before date selector when applicable)
if (selectedShift != null) // if (selectedShift != null)
CommuteTracker( // CommuteTracker(
shift: selectedShift, // shift: selectedShift,
hasLocationConsent: state.hasLocationConsent, // hasLocationConsent: state.hasLocationConsent,
isCommuteModeOn: state.isCommuteModeOn, // isCommuteModeOn: state.isCommuteModeOn,
distanceMeters: state.distanceFromVenue, // distanceMeters: state.distanceFromVenue,
etaMinutes: state.etaMinutes, // etaMinutes: state.etaMinutes,
onCommuteToggled: (bool value) { // onCommuteToggled: (bool value) {
_bloc.add(CommuteModeToggled(value)); // _bloc.add(CommuteModeToggled(value));
}, // },
), // ),
// Date Selector // Date Selector
DateSelector( DateSelector(
selectedDate: state.selectedDate, selectedDate: state.selectedDate,
@@ -141,15 +138,12 @@ class _ClockInPageState extends State<ClockInPage> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: borderRadius: UiConstants.radiusLg,
UiConstants.radiusLg,
border: Border.all( border: Border.all(
color: shift.id == color: shift.id == selectedShift?.id
selectedShift?.id
? UiColors.primary ? UiColors.primary
: UiColors.border, : UiColors.border,
width: width: shift.id == selectedShift?.id
shift.id == selectedShift?.id
? 2 ? 2
: 1, : 1,
), ),
@@ -166,15 +160,15 @@ class _ClockInPageState extends State<ClockInPage> {
Text( Text(
shift.id == shift.id ==
selectedShift?.id selectedShift?.id
? i18n ? i18n.selected_shift_badge
.selected_shift_badge : i18n.today_shift_badge,
: i18n
.today_shift_badge,
style: UiTypography style: UiTypography
.titleUppercase4b .titleUppercase4b
.copyWith( .copyWith(
color: shift.id == color:
selectedShift?.id shift.id ==
selectedShift
?.id
? UiColors.primary ? UiColors.primary
: UiColors : UiColors
.textSecondary, .textSecondary,
@@ -187,7 +181,8 @@ class _ClockInPageState extends State<ClockInPage> {
), ),
Text( Text(
"${shift.clientName} • ${shift.location}", "${shift.clientName} • ${shift.location}",
style: UiTypography.body3r style: UiTypography
.body3r
.textSecondary, .textSecondary,
), ),
], ],
@@ -199,7 +194,8 @@ class _ClockInPageState extends State<ClockInPage> {
children: <Widget>[ children: <Widget>[
Text( Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.body3m style: UiTypography
.body3m
.textSecondary, .textSecondary,
), ),
Text( Text(
@@ -226,8 +222,9 @@ class _ClockInPageState extends State<ClockInPage> {
!_isCheckInAllowed(selectedShift)) !_isCheckInAllowed(selectedShift))
Container( Container(
width: double.infinity, width: double.infinity,
padding: padding: const EdgeInsets.all(
const EdgeInsets.all(UiConstants.space6), UiConstants.space6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.bgSecondary, color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
@@ -259,81 +256,109 @@ class _ClockInPageState extends State<ClockInPage> {
) )
else ...<Widget>[ else ...<Widget>[
// Attire Photo Section // Attire Photo Section
if (!isCheckedIn) ...<Widget>[ // if (!isCheckedIn) ...<Widget>[
Container( // Container(
padding: const EdgeInsets.all(UiConstants.space4), // padding: const EdgeInsets.all(
margin: const EdgeInsets.only(bottom: UiConstants.space4), // UiConstants.space4,
decoration: BoxDecoration( // ),
color: UiColors.white, // margin: const EdgeInsets.only(
borderRadius: UiConstants.radiusLg, // bottom: UiConstants.space4,
border: Border.all(color: UiColors.border), // ),
), // decoration: BoxDecoration(
child: Row( // color: UiColors.white,
children: <Widget>[ // borderRadius: UiConstants.radiusLg,
Container( // border: Border.all(color: UiColors.border),
width: 48, // ),
height: 48, // child: Row(
decoration: BoxDecoration( // children: <Widget>[
color: UiColors.bgSecondary, // Container(
borderRadius: UiConstants.radiusMd, // width: 48,
), // height: 48,
child: const Icon(UiIcons.camera, color: UiColors.primary), // decoration: BoxDecoration(
), // color: UiColors.bgSecondary,
const SizedBox(width: UiConstants.space3), // borderRadius: UiConstants.radiusMd,
Expanded( // ),
child: Column( // child: const Icon(
crossAxisAlignment: CrossAxisAlignment.start, // UiIcons.camera,
children: <Widget>[ // color: UiColors.primary,
Text(i18n.attire_photo_label, style: UiTypography.body2b), // ),
Text(i18n.attire_photo_desc, style: UiTypography.body3r.textSecondary), // ),
], // const SizedBox(width: UiConstants.space3),
), // Expanded(
), // child: Column(
UiButton.secondary( // crossAxisAlignment:
text: i18n.take_attire_photo, // CrossAxisAlignment.start,
onPressed: () { // children: <Widget>[
UiSnackbar.show( // Text(
context, // i18n.attire_photo_label,
message: i18n.attire_captured, // style: UiTypography.body2b,
type: UiSnackbarType.success, // ),
); // Text(
}, // i18n.attire_photo_desc,
), // style: UiTypography
], // .body3r
), // .textSecondary,
), // ),
], // ],
// ),
if (!isCheckedIn && (!state.isLocationVerified || state.currentLocation == null)) ...<Widget>[ // ),
Container( // UiButton.secondary(
width: double.infinity, // text: i18n.take_attire_photo,
padding: const EdgeInsets.all(UiConstants.space4), // onPressed: () {
margin: const EdgeInsets.only(bottom: UiConstants.space4), // UiSnackbar.show(
decoration: BoxDecoration( // context,
color: UiColors.tagError, // message: i18n.attire_captured,
borderRadius: UiConstants.radiusLg, // type: UiSnackbarType.success,
), // );
child: Row( // },
children: [ // ),
const Icon(UiIcons.error, color: UiColors.textError, size: 20), // ],
const SizedBox(width: UiConstants.space3), // ),
Expanded( // ),
child: Text( // ],
state.currentLocation == null
? i18n.location_verifying
: i18n.not_in_range(distance: '500'),
style: UiTypography.body3m.textError,
),
),
],
),
),
],
// if (!isCheckedIn &&
// (!state.isLocationVerified ||
// state.currentLocation ==
// null)) ...<Widget>[
// Container(
// width: double.infinity,
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.tagError,
// borderRadius: UiConstants.radiusLg,
// ),
// child: Row(
// children: [
// const Icon(
// UiIcons.error,
// color: UiColors.textError,
// size: 20,
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Text(
// state.currentLocation == null
// ? i18n.location_verifying
// : i18n.not_in_range(
// distance: '500',
// ),
// style: UiTypography.body3m.textError,
// ),
// ),
// ],
// ),
// ),
// ],
SwipeToCheckIn( SwipeToCheckIn(
isCheckedIn: isCheckedIn, isCheckedIn: isCheckedIn,
mode: state.checkInMode, mode: state.checkInMode,
isDisabled: !isCheckedIn && !state.isLocationVerified, isDisabled: isCheckedIn,
isLoading: isLoading:
state.status == state.status ==
ClockInStatus.actionInProgress, ClockInStatus.actionInProgress,
@@ -554,7 +579,9 @@ class _ClockInPageState extends State<ClockInPage> {
} }
Future<void> _showNFCDialog(BuildContext context) async { Future<void> _showNFCDialog(BuildContext context) async {
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
bool scanned = false; bool scanned = false;
// Using a local navigator context since we are in a dialog // Using a local navigator context since we are in a dialog
@@ -668,7 +695,13 @@ class _ClockInPageState extends State<ClockInPage> {
try { try {
final List<String> parts = timeStr.split(':'); final List<String> parts = timeStr.split(':');
if (parts.length >= 2) { if (parts.length >= 2) {
final DateTime dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1])); final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt); return DateFormat('h:mm a').format(dt);
} }
return timeStr; return timeStr;
@@ -683,7 +716,9 @@ class _ClockInPageState extends State<ClockInPage> {
// Parse shift date (e.g. 2024-01-31T09:00:00) // Parse shift date (e.g. 2024-01-31T09:00:00)
// The Shift entity has 'date' which is the start DateTime string // The Shift entity has 'date' which is the start DateTime string
final DateTime shiftStart = DateTime.parse(shift.startTime); final DateTime shiftStart = DateTime.parse(shift.startTime);
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateTime.now().isAfter(windowStart); return DateTime.now().isAfter(windowStart);
} catch (e) { } catch (e) {
// Fallback: If parsing fails, allow check in to avoid blocking. // Fallback: If parsing fails, allow check in to avoid blocking.
@@ -694,13 +729,15 @@ class _ClockInPageState extends State<ClockInPage> {
String _getCheckInAvailabilityTime(Shift shift) { String _getCheckInAvailabilityTime(Shift shift) {
try { try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart); return DateFormat('h:mm a').format(windowStart);
} catch (e) { } catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon; return i18n.soon;
} }
} }
} }

View File

@@ -1,11 +1,10 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart';
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
part 'home_state.dart'; part 'home_state.dart';
@@ -14,18 +13,18 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
final GetHomeShifts _getHomeShifts; final GetHomeShifts _getHomeShifts;
final HomeRepository _repository; final HomeRepository _repository;
/// Use case that checks whether the staff member's personal info is complete. /// Use case that checks whether the staff member's profile is complete.
/// ///
/// Used to determine whether profile-gated features (such as shift browsing) /// Used to determine whether profile-gated features (such as shift browsing)
/// should be enabled on the home screen. /// should be enabled on the home screen.
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletion; final GetProfileCompletionUseCase _getProfileCompletion;
HomeCubit({ HomeCubit({
required HomeRepository repository, required HomeRepository repository,
required GetPersonalInfoCompletionUseCase getPersonalInfoCompletion, required GetProfileCompletionUseCase getProfileCompletion,
}) : _getHomeShifts = GetHomeShifts(repository), }) : _getHomeShifts = GetHomeShifts(repository),
_repository = repository, _repository = repository,
_getPersonalInfoCompletion = getPersonalInfoCompletion, _getProfileCompletion = getProfileCompletion,
super(const HomeState.initial()); super(const HomeState.initial());
Future<void> loadShifts() async { Future<void> loadShifts() async {
@@ -37,7 +36,7 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
// Fetch shifts, name, benefits and profile completion status concurrently // Fetch shifts, name, benefits and profile completion status concurrently
final results = await Future.wait([ final results = await Future.wait([
_getHomeShifts.call(), _getHomeShifts.call(),
_getPersonalInfoCompletion.call(), _getProfileCompletion.call(),
_repository.getBenefits(), _repository.getBenefits(),
_repository.getStaffName(), _repository.getStaffName(),
]); ]);

View File

@@ -61,12 +61,17 @@ class WorkerHomePage extends StatelessWidget {
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
vertical: UiConstants.space4, vertical: UiConstants.space4,
), ),
child: BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) =>
previous.isProfileComplete != current.isProfileComplete,
builder: (context, state) {
if (!state.isProfileComplete) {
return SizedBox(
height: MediaQuery.of(context).size.height -
300,
child: Column( child: Column(
children: [ children: [
BlocBuilder<HomeCubit, HomeState>( PlaceholderBanner(
builder: (context, state) {
if (state.isProfileComplete) return const SizedBox();
return PlaceholderBanner(
title: bannersI18n.complete_profile_title, title: bannersI18n.complete_profile_title,
subtitle: bannersI18n.complete_profile_subtitle, subtitle: bannersI18n.complete_profile_subtitle,
bg: UiColors.primaryInverse, bg: UiColors.primaryInverse,
@@ -74,12 +79,22 @@ class WorkerHomePage extends StatelessWidget {
onTap: () { onTap: () {
Modular.to.toProfile(); Modular.to.toProfile();
}, },
);
},
), ),
const SizedBox(height: UiConstants.space10),
Expanded(
child: UiEmptyState(
icon: UiIcons.users,
title: 'Complete Your Profile',
description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.',
),
),
],
),
);
}
const SizedBox(height: UiConstants.space6), return Column(
children: [
// Quick Actions // Quick Actions
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -224,6 +239,8 @@ class WorkerHomePage extends StatelessWidget {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
], ],
);
},
), ),
), ),
], ],

View File

@@ -24,9 +24,9 @@ class StaffHomeModule extends Module {
() => StaffConnectorRepositoryImpl(), () => StaffConnectorRepositoryImpl(),
); );
// Use case for checking personal info profile completion // Use case for checking profile completion
i.addLazySingleton<GetPersonalInfoCompletionUseCase>( i.addLazySingleton<GetProfileCompletionUseCase>(
() => GetPersonalInfoCompletionUseCase( () => GetProfileCompletionUseCase(
repository: i.get<StaffConnectorRepository>(), repository: i.get<StaffConnectorRepository>(),
), ),
); );
@@ -35,7 +35,7 @@ class StaffHomeModule extends Module {
i.addSingleton( i.addSingleton(
() => HomeCubit( () => HomeCubit(
repository: i.get<HomeRepository>(), repository: i.get<HomeRepository>(),
getPersonalInfoCompletion: i.get<GetPersonalInfoCompletionUseCase>(), getProfileCompletion: i.get<GetProfileCompletionUseCase>(),
), ),
); );
} }

View File

@@ -35,6 +35,23 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
String? certificateNumber, String? certificateNumber,
}) async { }) async {
return _service.run(() async { return _service.run(() async {
// Get existing certificate to check if file has changed
final List<domain.StaffCertificate> existingCerts = await getCertificates();
domain.StaffCertificate? existingCert;
try {
existingCert = existingCerts.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
} catch (e) {
// Certificate doesn't exist yet
}
String? signedUrl = existingCert?.certificateUrl;
String? verificationId = existingCert?.verificationId;
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
// Only upload and verify if file path has changed
if (fileChanged) {
// 1. Upload the file to cloud storage // 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile( final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath, filePath: filePath,
@@ -44,41 +61,37 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
); );
// 2. Generate a signed URL for verification service to access the file // 2. Generate a signed URL for verification service to access the file
// Wait, verification service might need this or just the URI. final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// Following DocumentRepository behavior: signedUrl = signedUrlRes.signedUrl;
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification // 3. Initiate verification
final List<domain.StaffCertificate> allCerts = await getCertificates();
final domain.StaffCertificate currentCert = allCerts.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService final VerificationResponse verificationRes = await _verificationService
.createVerification( .createVerification(
fileUri: uploadRes.fileUri, fileUri: uploadRes.fileUri,
type: certificationType.value, type: 'certification',
category: 'certification',
subjectType: 'worker', subjectType: 'worker',
subjectId: staffId, subjectId: staffId,
rules: <String, dynamic>{ rules: <String, dynamic>{
'certificateDescription': currentCert.description, 'certificateName': name,
'certificateIssuer': issuer,
'certificateNumber': certificateNumber,
}, },
); );
verificationId = verificationRes.verificationId;
}
// 4. Update/Create Certificate in Data Connect // 4. Update/Create Certificate in Data Connect
await _service.getStaffRepository().upsertStaffCertificate( await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType, certificationType: certificationType,
name: name, name: name,
status: domain.StaffCertificateStatus.pending, status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
fileUrl: uploadRes.fileUri, fileUrl: signedUrl,
expiry: expiryDate, expiry: expiryDate,
issuer: issuer, issuer: issuer,
certificateNumber: certificateNumber, certificateNumber: certificateNumber,
validationStatus: validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
domain.StaffCertificateValidationStatus.pendingExpertReview, verificationId: verificationId,
verificationId: verificationRes.verificationId,
); );
// 5. Return updated list or the specific certificate // 5. Return updated list or the specific certificate

View File

@@ -19,6 +19,10 @@ class CertificateUploadCubit extends Cubit<CertificateUploadState>
emit(state.copyWith(isAttested: value)); emit(state.copyWith(isAttested: value));
} }
void setSelectedFilePath(String? filePath) {
emit(state.copyWith(selectedFilePath: filePath));
}
Future<void> deleteCertificate(ComplianceType type) async { Future<void> deleteCertificate(ComplianceType type) async {
emit(state.copyWith(status: CertificateUploadStatus.uploading)); emit(state.copyWith(status: CertificateUploadStatus.uploading));
await handleError( await handleError(

View File

@@ -7,24 +7,28 @@ class CertificateUploadState extends Equatable {
const CertificateUploadState({ const CertificateUploadState({
this.status = CertificateUploadStatus.initial, this.status = CertificateUploadStatus.initial,
this.isAttested = false, this.isAttested = false,
this.selectedFilePath,
this.updatedCertificate, this.updatedCertificate,
this.errorMessage, this.errorMessage,
}); });
final CertificateUploadStatus status; final CertificateUploadStatus status;
final bool isAttested; final bool isAttested;
final String? selectedFilePath;
final StaffCertificate? updatedCertificate; final StaffCertificate? updatedCertificate;
final String? errorMessage; final String? errorMessage;
CertificateUploadState copyWith({ CertificateUploadState copyWith({
CertificateUploadStatus? status, CertificateUploadStatus? status,
bool? isAttested, bool? isAttested,
String? selectedFilePath,
StaffCertificate? updatedCertificate, StaffCertificate? updatedCertificate,
String? errorMessage, String? errorMessage,
}) { }) {
return CertificateUploadState( return CertificateUploadState(
status: status ?? this.status, status: status ?? this.status,
isAttested: isAttested ?? this.isAttested, isAttested: isAttested ?? this.isAttested,
selectedFilePath: selectedFilePath ?? this.selectedFilePath,
updatedCertificate: updatedCertificate ?? this.updatedCertificate, updatedCertificate: updatedCertificate ?? this.updatedCertificate,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
@@ -34,6 +38,7 @@ class CertificateUploadState extends Equatable {
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
isAttested, isAttested,
selectedFilePath,
updatedCertificate, updatedCertificate,
errorMessage, errorMessage,
]; ];

View File

@@ -1,17 +1,17 @@
import 'dart:io'; import 'dart:io';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:intl/intl.dart';
import '../../domain/usecases/upload_certificate_usecase.dart';
import '../blocs/certificate_upload/certificate_upload_cubit.dart'; import '../blocs/certificate_upload/certificate_upload_cubit.dart';
import '../blocs/certificate_upload/certificate_upload_state.dart'; import '../blocs/certificate_upload/certificate_upload_state.dart';
import '../../domain/usecases/upload_certificate_usecase.dart'; import '../widgets/certificate_upload_page/index.dart';
/// Page for uploading a certificate with metadata (expiry, issuer, etc). /// Page for uploading a certificate with metadata (expiry, issuer, etc).
class CertificateUploadPage extends StatefulWidget { class CertificateUploadPage extends StatefulWidget {
@@ -25,7 +25,6 @@ class CertificateUploadPage extends StatefulWidget {
} }
class _CertificateUploadPageState extends State<CertificateUploadPage> { class _CertificateUploadPageState extends State<CertificateUploadPage> {
String? _selectedFilePath;
DateTime? _selectedExpiryDate; DateTime? _selectedExpiryDate;
final TextEditingController _issuerController = TextEditingController(); final TextEditingController _issuerController = TextEditingController();
final TextEditingController _numberController = TextEditingController(); final TextEditingController _numberController = TextEditingController();
@@ -35,16 +34,21 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
final FilePickerService _filePicker = Modular.get<FilePickerService>(); final FilePickerService _filePicker = Modular.get<FilePickerService>();
bool get _isNewCertificate => widget.certificate == null;
late CertificateUploadCubit _cubit;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_cubit = Modular.get<CertificateUploadCubit>();
if (widget.certificate != null) { if (widget.certificate != null) {
_selectedExpiryDate = widget.certificate!.expiryDate; _selectedExpiryDate = widget.certificate!.expiryDate;
_issuerController.text = widget.certificate!.issuer ?? ''; _issuerController.text = widget.certificate!.issuer ?? '';
_numberController.text = widget.certificate!.certificateNumber ?? ''; _numberController.text = widget.certificate!.certificateNumber ?? '';
_nameController.text = widget.certificate!.name; _nameController.text = widget.certificate!.name;
_selectedType = widget.certificate!.certificationType; _selectedType = widget.certificate!.certificationType;
_selectedFilePath = widget.certificate?.certificateUrl;
} else { } else {
_selectedType = ComplianceType.other; _selectedType = ComplianceType.other;
} }
@@ -80,9 +84,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
); );
return; return;
} }
setState(() { _cubit.setSelectedFilePath(path);
_selectedFilePath = path;
});
} }
} }
@@ -145,8 +147,10 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CertificateUploadCubit>( return BlocProvider<CertificateUploadCubit>.value(
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(), value: _cubit..setSelectedFilePath(
widget.certificate?.certificateUrl,
),
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>( child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
listener: (BuildContext context, CertificateUploadState state) { listener: (BuildContext context, CertificateUploadState state) {
if (state.status == CertificateUploadStatus.success) { if (state.status == CertificateUploadStatus.success) {
@@ -155,7 +159,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
message: t.staff_certificates.upload_modal.success_snackbar, message: t.staff_certificates.upload_modal.success_snackbar,
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
Modular.to.pop(); // Returns to certificates list Modular.to.popSafe(); // Returns to certificates list
} else if (state.status == CertificateUploadStatus.failure) { } else if (state.status == CertificateUploadStatus.failure) {
UiSnackbar.show( UiSnackbar.show(
context, context,
@@ -170,69 +174,23 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
title: title:
widget.certificate?.name ?? widget.certificate?.name ??
t.staff_certificates.upload_modal.title, t.staff_certificates.upload_modal.title,
onLeadingPressed: () => Modular.to.pop(), onLeadingPressed: () => Modular.to.popSafe(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
_PdfFileTypesBanner( PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner, message: t.staff_documents.upload.pdf_banner,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Name Field CertificateMetadataFields(
Text( nameController: _nameController,
t.staff_certificates.upload_modal.name_label, issuerController: _issuerController,
style: UiTypography.body2m.textPrimary, numberController: _numberController,
), isNewCertificate: _isNewCertificate,
const SizedBox(height: UiConstants.space2),
TextField(
controller: _nameController,
enabled: false,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Issuer Field
Text(
t.staff_certificates.upload_modal.issuer_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _issuerController,
enabled: false,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.issuer_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Certificate Number Field
Text(
'Certificate Number',
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _numberController,
enabled: false,
decoration: InputDecoration(
hintText: 'Enter number if applicable',
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
@@ -240,44 +198,9 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Expiry Date Field ExpiryDateField(
Text( selectedDate: _selectedExpiryDate,
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: _selectDate, onTap: _selectDate,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space3),
Text(
_selectedExpiryDate != null
? DateFormat(
'MMM dd, yyyy',
).format(_selectedExpiryDate!)
: t.staff_certificates.upload_modal.select_date,
style: _selectedExpiryDate != null
? UiTypography.body1m.textPrimary
: UiTypography.body1m.textSecondary,
),
],
),
),
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
@@ -287,8 +210,8 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
_FileSelector( FileSelector(
selectedFilePath: _selectedFilePath, selectedFilePath: state.selectedFilePath,
onTap: _pickFile, onTap: _pickFile,
), ),
], ],
@@ -297,110 +220,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: CertificateUploadActions(
mainAxisSize: MainAxisSize.min, isAttested: state.isAttested,
spacing: UiConstants.space4, isFormValid: state.selectedFilePath != null &&
children: <Widget>[
// Attestation
Row(
children: <Widget>[
Checkbox(
value: state.isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(
context,
).setAttested(val ?? false),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body3r.textSecondary,
),
),
],
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
(_selectedFilePath != null &&
state.isAttested && state.isAttested &&
_nameController.text.isNotEmpty) _nameController.text.isNotEmpty,
? () { isUploading: state.status == CertificateUploadStatus.uploading,
final String? err = _validatePdfFile( hasExistingCertificate: widget.certificate != null,
context, onUploadPressed: () {
_selectedFilePath!, BlocProvider.of<CertificateUploadCubit>(context)
); .uploadCertificate(
if (err != null) {
UiSnackbar.show(
context,
message: err,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(
UiConstants.space4,
),
);
return;
}
BlocProvider.of<CertificateUploadCubit>(
context,
).uploadCertificate(
UploadCertificateParams( UploadCertificateParams(
certificationType: _selectedType!, certificationType: _selectedType!,
name: _nameController.text, name: _nameController.text,
filePath: _selectedFilePath!, filePath: state.selectedFilePath!,
expiryDate: _selectedExpiryDate, expiryDate: _selectedExpiryDate,
issuer: _issuerController.text, issuer: _issuerController.text,
certificateNumber: _numberController.text, certificateNumber: _numberController.text,
), ),
); );
} },
: null, onRemovePressed: () => _showRemoveConfirmation(context),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
child: state.status == CertificateUploadStatus.uploading
? const CircularProgressIndicator(
color: Colors.white,
)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
),
),
),
// Remove Button (only if existing)
if (widget.certificate != null) ...<Widget>[
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: () => _showRemoveConfirmation(context),
icon: const Icon(UiIcons.delete, size: 20),
label: Text(t.staff_certificates.card.remove),
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(
color: UiColors.destructive,
),
),
),
),
),
],
],
), ),
), ),
), ),
@@ -410,104 +250,3 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
); );
} }
} }
/// Banner displaying accepted file types and size limit for PDF upload.
class _PdfFileTypesBanner extends StatelessWidget {
const _PdfFileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(message, style: UiTypography.body2r.textSecondary),
),
],
),
);
}
}
class _FileSelector extends StatelessWidget {
const _FileSelector({this.selectedFilePath, required this.onTap});
final String? selectedFilePath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (selectedFilePath != null) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.certificate, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath!.split('/').last,
style: UiTypography.body1m.primary,
overflow: TextOverflow.ellipsis,
),
),
Text(
t.staff_documents.upload.replace,
style: UiTypography.body3m.primary,
),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
borderRadius: UiConstants.radiusLg,
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body2m,
),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -54,7 +54,10 @@ class CertificatesPage extends StatelessWidget {
final List<StaffCertificate> documents = state.certificates; final List<StaffCertificate> documents = state.certificates;
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC appBar: UiAppBar(
title: t.staff_certificates.title,
showBackButton: true,
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: <Widget>[ children: <Widget>[

View File

@@ -0,0 +1,79 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for certificate metadata input fields (name, issuer, number).
class CertificateMetadataFields extends StatelessWidget {
const CertificateMetadataFields({
required this.nameController,
required this.issuerController,
required this.numberController,
required this.isNewCertificate,
});
final TextEditingController nameController;
final TextEditingController issuerController;
final TextEditingController numberController;
final bool isNewCertificate;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Name Field
Text(
t.staff_certificates.upload_modal.name_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: nameController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Issuer Field
Text(
t.staff_certificates.upload_modal.issuer_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: issuerController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.issuer_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Certificate Number Field
Text(
'Certificate Number',
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: numberController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: 'Enter number if applicable',
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:core_localization/core_localization.dart';
import '../../blocs/certificate_upload/certificate_upload_cubit.dart';
/// Widget for attestation checkbox and action buttons in certificate upload form.
class CertificateUploadActions extends StatelessWidget {
const CertificateUploadActions({
required this.isAttested,
required this.isFormValid,
required this.isUploading,
required this.hasExistingCertificate,
required this.onUploadPressed,
required this.onRemovePressed,
});
final bool isAttested;
final bool isFormValid;
final bool isUploading;
final bool hasExistingCertificate;
final VoidCallback onUploadPressed;
final VoidCallback onRemovePressed;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
// Attestation
Row(
children: <Widget>[
Checkbox(
value: isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(context).setAttested(
val ?? false,
),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body3r.textSecondary,
),
),
],
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isFormValid ? onUploadPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
child: isUploading
? const CircularProgressIndicator(
color: Colors.white,
)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
),
),
),
// Remove Button (only if existing)
if (hasExistingCertificate) ...<Widget>[
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: onRemovePressed,
icon: const Icon(UiIcons.delete, size: 20),
label: Text(t.staff_certificates.card.remove),
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(
color: UiColors.destructive,
),
),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for selecting certificate expiry date.
class ExpiryDateField extends StatelessWidget {
const ExpiryDateField({
required this.selectedDate,
required this.onTap,
});
final DateTime? selectedDate;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space3),
Text(
selectedDate != null
? DateFormat('MMM dd, yyyy').format(selectedDate!)
: t.staff_certificates.upload_modal.select_date,
style: selectedDate != null
? UiTypography.body1m.textPrimary
: UiTypography.body1m.textSecondary,
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for selecting certificate file.
class FileSelector extends StatelessWidget {
const FileSelector({
super.key,
required this.selectedFilePath,
required this.onTap,
});
final String? selectedFilePath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (selectedFilePath != null) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.certificate, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath!.split('/').last,
style: UiTypography.body1m.primary,
overflow: TextOverflow.ellipsis,
),
),
Text(
t.staff_documents.upload.replace,
style: UiTypography.body3m.primary,
),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
borderRadius: UiConstants.radiusLg,
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body2m,
),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,5 @@
export 'certificate_metadata_fields.dart';
export 'certificate_upload_actions.dart';
export 'expiry_date_field.dart';
export 'file_selector.dart';
export 'pdf_file_types_banner.dart';

View File

@@ -0,0 +1,14 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for PDF upload.
class PdfFileTypesBanner extends StatelessWidget {
const PdfFileTypesBanner({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return UiNoticeBanner(title: message, icon: UiIcons.info);
}
}

View File

@@ -1,10 +1,8 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
class CertificatesHeader extends StatelessWidget { class CertificatesHeader extends StatelessWidget {
const CertificatesHeader({ const CertificatesHeader({
super.key, super.key,
required this.completedCount, required this.completedCount,
@@ -16,8 +14,12 @@ class CertificatesHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Prevent division by zero // Prevent division by zero
final double progressValue = totalCount == 0 ? 0 : completedCount / totalCount; final double progressValue = totalCount == 0
final int progressPercent = totalCount == 0 ? 0 : (progressValue * 100).round(); ? 0
: completedCount / totalCount;
final int progressPercent = totalCount == 0
? 0
: (progressValue * 100).round();
return Container( return Container(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
@@ -32,39 +34,13 @@ class CertificatesHeader extends StatelessWidget {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: <Color>[ colors: <Color>[
UiColors.primary,
UiColors.primary.withValues(alpha: 0.8), UiColors.primary.withValues(alpha: 0.8),
UiColors.primary.withValues(alpha: 0.5),
], ],
), ),
), ),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.pop(),
child: Container(
width: UiConstants.space10,
height: UiConstants.space10,
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.chevronLeft,
color: UiColors.white,
size: UiConstants.iconMd,
),
),
),
const SizedBox(width: UiConstants.space3),
Text(
t.staff_certificates.title,
style: UiTypography.headline3m.white,
),
],
),
const SizedBox(height: UiConstants.space8),
Row( Row(
children: <Widget>[ children: <Widget>[
SizedBox( SizedBox(
@@ -101,7 +77,9 @@ class CertificatesHeader extends StatelessWidget {
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
t.staff_certificates.progress.verified_count( t.staff_certificates.progress.verified_count(
completed: completedCount, total: totalCount), completed: completedCount,
total: totalCount,
),
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),

View File

@@ -50,21 +50,11 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
); );
final String description = (currentDoc.description ?? '').toLowerCase(); final String description = (currentDoc.description ?? '').toLowerCase();
String verificationType = 'government_id';
if (description.contains('permit')) {
verificationType = 'work_permit';
} else if (description.contains('passport')) {
verificationType = 'passport';
} else if (description.contains('ssn') ||
description.contains('social security')) {
verificationType = 'ssn';
}
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService final VerificationResponse verificationRes = await _verificationService
.createVerification( .createVerification(
fileUri: uploadRes.fileUri, fileUri: uploadRes.fileUri,
type: verificationType, type: 'government_id',
subjectType: 'worker', subjectType: 'worker',
subjectId: staffId, subjectId: staffId,
rules: <String, dynamic>{ rules: <String, dynamic>{
@@ -75,7 +65,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
// 4. Update/Create StaffDocument in Data Connect // 4. Update/Create StaffDocument in Data Connect
await _service.getStaffRepository().upsertStaffDocument( await _service.getStaffRepository().upsertStaffDocument(
documentId: documentId, documentId: documentId,
documentUrl: uploadRes.fileUri, documentUrl: signedUrlRes.signedUrl,
status: domain.DocumentStatus.pending, status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId, verificationId: verificationRes.verificationId,
); );

View File

@@ -19,6 +19,11 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
emit(state.copyWith(isAttested: value)); emit(state.copyWith(isAttested: value));
} }
/// Sets the selected file path for the document.
void setSelectedFilePath(String filePath) {
emit(state.copyWith(selectedFilePath: filePath));
}
/// Uploads the selected document if the user has attested. /// Uploads the selected document if the user has attested.
/// ///
/// Requires [state.isAttested] to be true before proceeding. /// Requires [state.isAttested] to be true before proceeding.

View File

@@ -7,6 +7,7 @@ class DocumentUploadState extends Equatable {
const DocumentUploadState({ const DocumentUploadState({
this.status = DocumentUploadStatus.initial, this.status = DocumentUploadStatus.initial,
this.isAttested = false, this.isAttested = false,
this.selectedFilePath,
this.documentUrl, this.documentUrl,
this.updatedDocument, this.updatedDocument,
this.errorMessage, this.errorMessage,
@@ -14,6 +15,7 @@ class DocumentUploadState extends Equatable {
final DocumentUploadStatus status; final DocumentUploadStatus status;
final bool isAttested; final bool isAttested;
final String? selectedFilePath;
final String? documentUrl; final String? documentUrl;
final StaffDocument? updatedDocument; final StaffDocument? updatedDocument;
final String? errorMessage; final String? errorMessage;
@@ -21,6 +23,7 @@ class DocumentUploadState extends Equatable {
DocumentUploadState copyWith({ DocumentUploadState copyWith({
DocumentUploadStatus? status, DocumentUploadStatus? status,
bool? isAttested, bool? isAttested,
String? selectedFilePath,
String? documentUrl, String? documentUrl,
StaffDocument? updatedDocument, StaffDocument? updatedDocument,
String? errorMessage, String? errorMessage,
@@ -28,6 +31,7 @@ class DocumentUploadState extends Equatable {
return DocumentUploadState( return DocumentUploadState(
status: status ?? this.status, status: status ?? this.status,
isAttested: isAttested ?? this.isAttested, isAttested: isAttested ?? this.isAttested,
selectedFilePath: selectedFilePath ?? this.selectedFilePath,
documentUrl: documentUrl ?? this.documentUrl, documentUrl: documentUrl ?? this.documentUrl,
updatedDocument: updatedDocument ?? this.updatedDocument, updatedDocument: updatedDocument ?? this.updatedDocument,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
@@ -38,6 +42,7 @@ class DocumentUploadState extends Equatable {
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
isAttested, isAttested,
selectedFilePath,
documentUrl, documentUrl,
updatedDocument, updatedDocument,
errorMessage, errorMessage,

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -13,12 +11,13 @@ import '../blocs/document_upload/document_upload_state.dart';
import '../widgets/document_upload/document_attestation_checkbox.dart'; import '../widgets/document_upload/document_attestation_checkbox.dart';
import '../widgets/document_upload/document_file_selector.dart'; import '../widgets/document_upload/document_file_selector.dart';
import '../widgets/document_upload/document_upload_footer.dart'; import '../widgets/document_upload/document_upload_footer.dart';
import '../widgets/document_upload/pdf_file_types_banner.dart';
/// Allows staff to select and submit a single PDF document for verification. /// Allows staff to select and submit a single PDF document for verification.
/// ///
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow: /// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
/// file selection → attestation → submit → poll for result. /// file selection → attestation → submit → poll for result.
class DocumentUploadPage extends StatefulWidget { class DocumentUploadPage extends StatelessWidget {
const DocumentUploadPage({ const DocumentUploadPage({
super.key, super.key,
required this.document, required this.document,
@@ -31,64 +30,17 @@ class DocumentUploadPage extends StatefulWidget {
/// Optional URL of an already-uploaded document. /// Optional URL of an already-uploaded document.
final String? initialUrl; final String? initialUrl;
@override
State<DocumentUploadPage> createState() => _DocumentUploadPageState();
}
class _DocumentUploadPageState extends State<DocumentUploadPage> {
String? _selectedFilePath;
final FilePickerService _filePicker = Modular.get<FilePickerService>();
static const int _kMaxFileSizeBytes = 10 * 1024 * 1024;
Future<void> _pickFile() async {
final String? path = await _filePicker.pickFile(
allowedExtensions: <String>['pdf'],
);
if (!mounted) {
return;
}
if (path != null) {
final String? error = _validatePdfFile(context, path);
if (error != null) {
UiSnackbar.show(
context,
message: error,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
setState(() {
_selectedFilePath = path;
});
}
}
String? _validatePdfFile(BuildContext context, String path) {
final File file = File(path);
if (!file.existsSync()) return context.t.common.file_not_found;
final String ext = path.split('.').last.toLowerCase();
if (ext != 'pdf') {
return context.t.staff_documents.upload.pdf_banner;
}
final int size = file.lengthSync();
if (size > _kMaxFileSizeBytes) {
return context.t.staff_documents.upload.pdf_banner;
}
return null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.initialUrl != null) {
_selectedFilePath = widget.initialUrl;
}
return BlocProvider<DocumentUploadCubit>( return BlocProvider<DocumentUploadCubit>(
create: (BuildContext _) => Modular.get<DocumentUploadCubit>(), create: (BuildContext _) {
final DocumentUploadCubit cubit =
Modular.get<DocumentUploadCubit>();
if (initialUrl != null) {
cubit.setSelectedFilePath(initialUrl!);
}
return cubit;
},
child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>( child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>(
listener: (BuildContext context, DocumentUploadState state) { listener: (BuildContext context, DocumentUploadState state) {
if (state.status == DocumentUploadStatus.success) { if (state.status == DocumentUploadStatus.success) {
@@ -109,8 +61,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
builder: (BuildContext context, DocumentUploadState state) { builder: (BuildContext context, DocumentUploadState state) {
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
title: widget.document.name, title: document.name,
subtitle: widget.document.description, subtitle: document.description,
onLeadingPressed: () => Modular.to.toDocuments(), onLeadingPressed: () => Modular.to.toDocuments(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@@ -118,13 +70,16 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
_PdfFileTypesBanner( PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner, message: t.staff_documents.upload.pdf_banner,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
DocumentFileSelector( DocumentFileSelector(
selectedFilePath: _selectedFilePath, selectedFilePath: state.selectedFilePath,
onTap: _pickFile, onFileSelected: (String path) {
BlocProvider.of<DocumentUploadCubit>(context)
.setSelectedFilePath(path);
},
), ),
], ],
), ),
@@ -150,26 +105,12 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
DocumentUploadFooter( DocumentUploadFooter(
isUploading: isUploading:
state.status == DocumentUploadStatus.uploading, state.status == DocumentUploadStatus.uploading,
canSubmit: _selectedFilePath != null && state.isAttested, canSubmit: state.selectedFilePath != null && state.isAttested,
onSubmit: () { onSubmit: () {
final String? err = _validatePdfFile( BlocProvider.of<DocumentUploadCubit>(context)
context, .uploadDocument(
_selectedFilePath!, document.documentId,
); state.selectedFilePath!,
if (err != null) {
UiSnackbar.show(
context,
message: err,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
BlocProvider.of<DocumentUploadCubit>(
context,
).uploadDocument(
widget.document.documentId,
_selectedFilePath!,
); );
}, },
), ),
@@ -183,36 +124,3 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
); );
} }
} }
/// Banner displaying accepted file types and size limit for PDF upload.
class _PdfFileTypesBanner extends StatelessWidget {
const _PdfFileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(message, style: UiTypography.body2r.textSecondary),
),
],
),
);
}
}

View File

@@ -1,7 +1,11 @@
import 'dart:io';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages // ignore: depend_on_referenced_packages
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'document_selected_card.dart'; import 'document_selected_card.dart';
@@ -9,33 +13,89 @@ import 'document_selected_card.dart';
/// ///
/// Shows the selected file name when a file has been chosen, or an /// Shows the selected file name when a file has been chosen, or an
/// upload icon with a prompt when no file is selected yet. /// upload icon with a prompt when no file is selected yet.
class DocumentFileSelector extends StatelessWidget { class DocumentFileSelector extends StatefulWidget {
const DocumentFileSelector({ const DocumentFileSelector({
super.key, super.key,
required this.onTap, this.onFileSelected,
this.selectedFilePath, this.selectedFilePath,
}); });
/// Called when the user taps the selector to pick a file. /// Called when a file is successfully selected and validated.
final VoidCallback onTap; final Function(String)? onFileSelected;
/// The local path of the currently selected file, or null if none chosen. /// The local path of the currently selected file, or null if none chosen.
final String? selectedFilePath; final String? selectedFilePath;
bool get _hasFile => selectedFilePath != null; @override
State<DocumentFileSelector> createState() => _DocumentFileSelectorState();
}
class _DocumentFileSelectorState extends State<DocumentFileSelector> {
late String? _selectedFilePath;
final FilePickerService _filePicker = Modular.get<FilePickerService>();
static const int _kMaxFileSizeBytes = 10 * 1024 * 1024;
@override
void initState() {
super.initState();
_selectedFilePath = widget.selectedFilePath;
}
bool get _hasFile => _selectedFilePath != null;
Future<void> _pickFile() async {
final String? path = await _filePicker.pickFile(
allowedExtensions: <String>['pdf'],
);
if (!mounted) {
return;
}
if (path != null) {
final String? error = _validatePdfFile(context, path);
if (error != null) {
UiSnackbar.show(
context,
message: error,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
setState(() {
_selectedFilePath = path;
});
widget.onFileSelected?.call(path);
}
}
String? _validatePdfFile(BuildContext context, String path) {
final File file = File(path);
if (!file.existsSync()) return context.t.common.file_not_found;
final String ext = path.split('.').last.toLowerCase();
if (ext != 'pdf') {
return context.t.staff_documents.upload.pdf_banner;
}
final int size = file.lengthSync();
if (size > _kMaxFileSizeBytes) {
return context.t.staff_documents.upload.pdf_banner;
}
return null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_hasFile) { if (_hasFile) {
return InkWell( return InkWell(
onTap: onTap, onTap: _pickFile,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
child: DocumentSelectedCard(selectedFilePath: selectedFilePath!), child: DocumentSelectedCard(selectedFilePath: _selectedFilePath!),
); );
} }
return InkWell( return InkWell(
onTap: onTap, onTap: _pickFile,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
child: Container( child: Container(
height: 180, height: 180,

View File

@@ -0,0 +1,14 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for PDF upload.
class PdfFileTypesBanner extends StatelessWidget {
const PdfFileTypesBanner({required this.message, super.key});
final String message;
@override
Widget build(BuildContext context) {
return UiNoticeBanner(title: message, icon: UiIcons.info);
}
}

View File

@@ -1,8 +1,10 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:flutter_modular/flutter_modular.dart'
hide ModularWatchExtension;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/i9/form_i9_cubit.dart'; import '../blocs/i9/form_i9_cubit.dart';
@@ -18,11 +20,56 @@ class FormI9Page extends StatefulWidget {
class _FormI9PageState extends State<FormI9Page> { class _FormI9PageState extends State<FormI9Page> {
final List<String> _usStates = <String>[ final List<String> _usStates = <String>[
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'AL',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'AK',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'AZ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'AR',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY' 'CA',
'CO',
'CT',
'DE',
'FL',
'GA',
'HI',
'ID',
'IL',
'IN',
'IA',
'KS',
'KY',
'LA',
'ME',
'MD',
'MA',
'MI',
'MN',
'MS',
'MO',
'MT',
'NE',
'NV',
'NH',
'NJ',
'NM',
'NY',
'NC',
'ND',
'OH',
'OK',
'OR',
'PA',
'RI',
'SC',
'SD',
'TN',
'TX',
'UT',
'VT',
'VA',
'WA',
'WV',
'WI',
'WY',
]; ];
@override @override
@@ -36,10 +83,19 @@ class _FormI9PageState extends State<FormI9Page> {
} }
final List<Map<String, String>> _steps = <Map<String, String>>[ final List<Map<String, String>> _steps = <Map<String, String>>[
<String, String>{'title': 'Personal Information', 'subtitle': 'Name and contact details'}, <String, String>{
'title': 'Personal Information',
'subtitle': 'Name and contact details',
},
<String, String>{'title': 'Address', 'subtitle': 'Your current address'}, <String, String>{'title': 'Address', 'subtitle': 'Your current address'},
<String, String>{'title': 'Citizenship Status', 'subtitle': 'Work authorization verification'}, <String, String>{
<String, String>{'title': 'Review & Sign', 'subtitle': 'Confirm your information'}, 'title': 'Citizenship Status',
'subtitle': 'Work authorization verification',
},
<String, String>{
'title': 'Review & Sign',
'subtitle': 'Confirm your information',
},
]; ];
bool _canProceed(FormI9State state) { bool _canProceed(FormI9State state) {
@@ -77,13 +133,27 @@ class _FormI9PageState extends State<FormI9Page> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(
context,
).staff_compliance.tax_forms.i9;
final List<Map<String, String>> steps = <Map<String, String>>[ final List<Map<String, String>> steps = <Map<String, String>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_sub}, <String, String>{
<String, String>{'title': i18n.steps.address, 'subtitle': i18n.steps.address_sub}, 'title': i18n.steps.personal,
<String, String>{'title': i18n.steps.citizenship, 'subtitle': i18n.steps.citizenship_sub}, 'subtitle': i18n.steps.personal_sub,
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.steps.review_sub}, },
<String, String>{
'title': i18n.steps.address,
'subtitle': i18n.steps.address_sub,
},
<String, String>{
'title': i18n.steps.citizenship,
'subtitle': i18n.steps.citizenship_sub,
},
<String, String>{
'title': i18n.steps.review,
'subtitle': i18n.steps.review_sub,
},
]; ];
return BlocProvider<FormI9Cubit>.value( return BlocProvider<FormI9Cubit>.value(
@@ -95,7 +165,9 @@ class _FormI9PageState extends State<FormI9Page> {
} else if (state.status == FormI9Status.failure) { } else if (state.status == FormI9Status.failure) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.errorMessage ?? 'An error occurred'), message: translateErrorKey(
state.errorMessage ?? 'An error occurred',
),
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.only( margin: const EdgeInsets.only(
left: UiConstants.space4, left: UiConstants.space4,
@@ -106,7 +178,8 @@ class _FormI9PageState extends State<FormI9Page> {
} }
}, },
builder: (BuildContext context, FormI9State state) { builder: (BuildContext context, FormI9State state) {
if (state.status == FormI9Status.success) return _buildSuccessView(i18n); if (state.status == FormI9Status.success)
return _buildSuccessView(i18n);
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
@@ -175,7 +248,7 @@ class _FormI9PageState extends State<FormI9Page> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: () => Modular.to.pop(true), onPressed: () => Modular.to.popSafe(true),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
foregroundColor: UiColors.white, foregroundColor: UiColors.white,
@@ -187,7 +260,11 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
elevation: 0, elevation: 0,
), ),
child: Text(Translations.of(context).staff_compliance.tax_forms.w4.back_to_docs), child: Text(
Translations.of(
context,
).staff_compliance.tax_forms.w4.back_to_docs,
),
), ),
), ),
], ],
@@ -218,7 +295,7 @@ class _FormI9PageState extends State<FormI9Page> {
Row( Row(
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onTap: () => Modular.to.pop(), onTap: () => Modular.to.popSafe(),
child: const Icon( child: const Icon(
UiIcons.arrowLeft, UiIcons.arrowLeft,
color: UiColors.white, color: UiColors.white,
@@ -229,10 +306,7 @@ class _FormI9PageState extends State<FormI9Page> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(i18n.title, style: UiTypography.headline4m.white),
i18n.title,
style: UiTypography.headline4m.white,
),
Text( Text(
i18n.subtitle, i18n.subtitle,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
@@ -245,10 +319,9 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Row( Row(
children: steps children: steps.asMap().entries.map((
.asMap() MapEntry<int, Map<String, String>> entry,
.entries ) {
.map((MapEntry<int, Map<String, String>> entry) {
final int idx = entry.key; final int idx = entry.key;
final bool isLast = idx == steps.length - 1; final bool isLast = idx == steps.length - 1;
return Expanded( return Expanded(
@@ -384,7 +457,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.first_name, i18n.fields.first_name,
value: state.firstName, value: state.firstName,
onChanged: (String val) => context.read<FormI9Cubit>().firstNameChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().firstNameChanged(val),
placeholder: i18n.fields.hints.first_name, placeholder: i18n.fields.hints.first_name,
), ),
), ),
@@ -393,7 +467,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.last_name, i18n.fields.last_name,
value: state.lastName, value: state.lastName,
onChanged: (String val) => context.read<FormI9Cubit>().lastNameChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().lastNameChanged(val),
placeholder: i18n.fields.hints.last_name, placeholder: i18n.fields.hints.last_name,
), ),
), ),
@@ -406,7 +481,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.middle_initial, i18n.fields.middle_initial,
value: state.middleInitial, value: state.middleInitial,
onChanged: (String val) => context.read<FormI9Cubit>().middleInitialChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().middleInitialChanged(val),
placeholder: i18n.fields.hints.middle_initial, placeholder: i18n.fields.hints.middle_initial,
), ),
), ),
@@ -416,7 +492,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.other_last_names, i18n.fields.other_last_names,
value: state.otherLastNames, value: state.otherLastNames,
onChanged: (String val) => context.read<FormI9Cubit>().otherLastNamesChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().otherLastNamesChanged(val),
placeholder: i18n.fields.maiden_name, placeholder: i18n.fields.maiden_name,
), ),
), ),
@@ -426,7 +503,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.dob, i18n.fields.dob,
value: state.dob, value: state.dob,
onChanged: (String val) => context.read<FormI9Cubit>().dobChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().dobChanged(val),
placeholder: i18n.fields.hints.dob, placeholder: i18n.fields.hints.dob,
keyboardType: TextInputType.datetime, keyboardType: TextInputType.datetime,
), ),
@@ -446,7 +524,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.email, i18n.fields.email,
value: state.email, value: state.email,
onChanged: (String val) => context.read<FormI9Cubit>().emailChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().emailChanged(val),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
placeholder: i18n.fields.hints.email, placeholder: i18n.fields.hints.email,
), ),
@@ -454,7 +533,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.phone, i18n.fields.phone,
value: state.phone, value: state.phone,
onChanged: (String val) => context.read<FormI9Cubit>().phoneChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().phoneChanged(val),
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
placeholder: i18n.fields.hints.phone, placeholder: i18n.fields.hints.phone,
), ),
@@ -472,14 +552,16 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.address_long, i18n.fields.address_long,
value: state.address, value: state.address,
onChanged: (String val) => context.read<FormI9Cubit>().addressChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().addressChanged(val),
placeholder: i18n.fields.hints.address, placeholder: i18n.fields.hints.address,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildTextField( _buildTextField(
i18n.fields.apt, i18n.fields.apt,
value: state.aptNumber, value: state.aptNumber,
onChanged: (String val) => context.read<FormI9Cubit>().aptNumberChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().aptNumberChanged(val),
placeholder: i18n.fields.hints.apt, placeholder: i18n.fields.hints.apt,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
@@ -490,7 +572,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.city, i18n.fields.city,
value: state.city, value: state.city,
onChanged: (String val) => context.read<FormI9Cubit>().cityChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().cityChanged(val),
placeholder: i18n.fields.hints.city, placeholder: i18n.fields.hints.city,
), ),
), ),
@@ -541,7 +624,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.zip, i18n.fields.zip,
value: state.zipCode, value: state.zipCode,
onChanged: (String val) => context.read<FormI9Cubit>().zipCodeChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().zipCodeChanged(val),
placeholder: i18n.fields.hints.zip, placeholder: i18n.fields.hints.zip,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
@@ -557,24 +641,11 @@ class _FormI9PageState extends State<FormI9Page> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(i18n.fields.attestation, style: UiTypography.body2m.textPrimary),
i18n.fields.attestation,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
_buildRadioOption( _buildRadioOption(context, state, 'CITIZEN', i18n.fields.citizen),
context,
state,
'CITIZEN',
i18n.fields.citizen,
),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildRadioOption( _buildRadioOption(context, state, 'NONCITIZEN', i18n.fields.noncitizen),
context,
state,
'NONCITIZEN',
i18n.fields.noncitizen,
),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildRadioOption( _buildRadioOption(
context, context,
@@ -587,7 +658,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.uscis_number_label, i18n.fields.uscis_number_label,
value: state.uscisNumber, value: state.uscisNumber,
onChanged: (String val) => context.read<FormI9Cubit>().uscisNumberChanged(val), onChanged: (String val) =>
context.read<FormI9Cubit>().uscisNumberChanged(val),
placeholder: i18n.fields.hints.uscis, placeholder: i18n.fields.hints.uscis,
), ),
) )
@@ -607,19 +679,25 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField( _buildTextField(
i18n.fields.admission_number, i18n.fields.admission_number,
value: state.admissionNumber, value: state.admissionNumber,
onChanged: (String val) => context.read<FormI9Cubit>().admissionNumberChanged(val), onChanged: (String val) => context
.read<FormI9Cubit>()
.admissionNumberChanged(val),
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildTextField( _buildTextField(
i18n.fields.passport, i18n.fields.passport,
value: state.passportNumber, value: state.passportNumber,
onChanged: (String val) => context.read<FormI9Cubit>().passportNumberChanged(val), onChanged: (String val) => context
.read<FormI9Cubit>()
.passportNumberChanged(val),
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildTextField( _buildTextField(
i18n.fields.country, i18n.fields.country,
value: state.countryIssuance, value: state.countryIssuance,
onChanged: (String val) => context.read<FormI9Cubit>().countryIssuanceChanged(val), onChanged: (String val) => context
.read<FormI9Cubit>()
.countryIssuanceChanged(val),
), ),
], ],
), ),
@@ -667,10 +745,7 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Text( child: Text(label, style: UiTypography.body2m.textPrimary),
label,
style: UiTypography.body2m.textPrimary,
),
), ),
], ],
), ),
@@ -704,8 +779,14 @@ class _FormI9PageState extends State<FormI9Page> {
style: UiTypography.headline4m.copyWith(fontSize: 14), style: UiTypography.headline4m.copyWith(fontSize: 14),
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'), _buildSummaryRow(
_buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'), i18n.fields.summary_name,
'${state.firstName} ${state.lastName}',
),
_buildSummaryRow(
i18n.fields.summary_address,
'${state.address}, ${state.city}',
),
_buildSummaryRow( _buildSummaryRow(
i18n.fields.summary_ssn, i18n.fields.summary_ssn,
'***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}',
@@ -780,10 +861,7 @@ class _FormI9PageState extends State<FormI9Page> {
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18), style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
i18n.fields.date_label,
style: UiTypography.body3m.textSecondary,
),
const SizedBox(height: UiConstants.space1 + 2), const SizedBox(height: UiConstants.space1 + 2),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -811,10 +889,7 @@ class _FormI9PageState extends State<FormI9Page> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(label, style: UiTypography.body2r.textSecondary),
label,
style: UiTypography.body2r.textSecondary,
),
Expanded( Expanded(
child: Text( child: Text(
value, value,
@@ -828,7 +903,9 @@ class _FormI9PageState extends State<FormI9Page> {
} }
String _getReadableCitizenship(String status) { String _getReadableCitizenship(String status) {
final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields; final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(
context,
).staff_compliance.tax_forms.i9.fields;
switch (status) { switch (status) {
case 'CITIZEN': case 'CITIZEN':
return i18n.status_us_citizen; return i18n.status_us_citizen;
@@ -848,7 +925,9 @@ class _FormI9PageState extends State<FormI9Page> {
FormI9State state, FormI9State state,
List<Map<String, String>> steps, List<Map<String, String>> steps,
) { ) {
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(
context,
).staff_compliance.tax_forms.i9;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
@@ -883,10 +962,7 @@ class _FormI9PageState extends State<FormI9Page> {
color: UiColors.textPrimary, color: UiColors.textPrimary,
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
Text( Text(i18n.back, style: UiTypography.body2r.textPrimary),
i18n.back,
style: UiTypography.body2r.textPrimary,
),
], ],
), ),
), ),
@@ -895,8 +971,8 @@ class _FormI9PageState extends State<FormI9Page> {
Expanded( Expanded(
flex: 2, flex: 2,
child: ElevatedButton( child: ElevatedButton(
onPressed: ( onPressed:
_canProceed(state) && (_canProceed(state) &&
state.status != FormI9Status.submitting) state.status != FormI9Status.submitting)
? () => _handleNext(context, state.currentStep) ? () => _handleNext(context, state.currentStep)
: null, : null,
@@ -931,7 +1007,11 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
if (state.currentStep < steps.length - 1) ...<Widget>[ if (state.currentStep < steps.length - 1) ...<Widget>[
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), const Icon(
UiIcons.arrowRight,
size: 16,
color: UiColors.white,
),
], ],
], ],
), ),

View File

@@ -2,8 +2,10 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:flutter_modular/flutter_modular.dart'
hide ModularWatchExtension;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/w4/form_w4_cubit.dart'; import '../blocs/w4/form_w4_cubit.dart';
@@ -84,7 +86,10 @@ class _FormW4PageState extends State<FormW4Page> {
<String, String>{'title': 'Filing Status', 'subtitle': 'Step 1c'}, <String, String>{'title': 'Filing Status', 'subtitle': 'Step 1c'},
<String, String>{'title': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'}, <String, String>{'title': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'},
<String, String>{'title': 'Dependents', 'subtitle': 'Step 3'}, <String, String>{'title': 'Dependents', 'subtitle': 'Step 3'},
<String, String>{'title': 'Other Adjustments', 'subtitle': 'Step 4 (optional)'}, <String, String>{
'title': 'Other Adjustments',
'subtitle': 'Step 4 (optional)',
},
<String, String>{'title': 'Review & Sign', 'subtitle': 'Step 5'}, <String, String>{'title': 'Review & Sign', 'subtitle': 'Step 5'},
]; ];
@@ -116,23 +121,41 @@ class _FormW4PageState extends State<FormW4Page> {
context.read<FormW4Cubit>().previousStep(); context.read<FormW4Cubit>().previousStep();
} }
int _totalCredits(FormW4State state) { int _totalCredits(FormW4State state) {
return (state.qualifyingChildren * 2000) + return (state.qualifyingChildren * 2000) + (state.otherDependents * 500);
(state.otherDependents * 500);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(
context,
).staff_compliance.tax_forms.w4;
final List<Map<String, String>> steps = <Map<String, String>>[ final List<Map<String, String>> steps = <Map<String, String>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')}, <String, String>{
<String, String>{'title': i18n.steps.filing, 'subtitle': i18n.step_label(current: '1c', total: '5')}, 'title': i18n.steps.personal,
<String, String>{'title': i18n.steps.multiple_jobs, 'subtitle': i18n.step_label(current: '2', total: '5')}, 'subtitle': i18n.step_label(current: '1', total: '5'),
<String, String>{'title': i18n.steps.dependents, 'subtitle': i18n.step_label(current: '3', total: '5')}, },
<String, String>{'title': i18n.steps.adjustments, 'subtitle': i18n.step_label(current: '4', total: '5')}, <String, String>{
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.step_label(current: '5', total: '5')}, 'title': i18n.steps.filing,
'subtitle': i18n.step_label(current: '1c', total: '5'),
},
<String, String>{
'title': i18n.steps.multiple_jobs,
'subtitle': i18n.step_label(current: '2', total: '5'),
},
<String, String>{
'title': i18n.steps.dependents,
'subtitle': i18n.step_label(current: '3', total: '5'),
},
<String, String>{
'title': i18n.steps.adjustments,
'subtitle': i18n.step_label(current: '4', total: '5'),
},
<String, String>{
'title': i18n.steps.review,
'subtitle': i18n.step_label(current: '5', total: '5'),
},
]; ];
return BlocProvider<FormW4Cubit>.value( return BlocProvider<FormW4Cubit>.value(
@@ -144,7 +167,9 @@ class _FormW4PageState extends State<FormW4Page> {
} else if (state.status == FormW4Status.failure) { } else if (state.status == FormW4Status.failure) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.errorMessage ?? 'An error occurred'), message: translateErrorKey(
state.errorMessage ?? 'An error occurred',
),
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.only( margin: const EdgeInsets.only(
left: UiConstants.space4, left: UiConstants.space4,
@@ -155,7 +180,8 @@ class _FormW4PageState extends State<FormW4Page> {
} }
}, },
builder: (BuildContext context, FormW4State state) { builder: (BuildContext context, FormW4State state) {
if (state.status == FormW4Status.success) return _buildSuccessView(i18n); if (state.status == FormW4Status.success)
return _buildSuccessView(i18n);
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
@@ -224,7 +250,7 @@ class _FormW4PageState extends State<FormW4Page> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: () => Modular.to.pop(true), onPressed: () => Modular.to.popSafe(true),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
foregroundColor: UiColors.white, foregroundColor: UiColors.white,
@@ -267,7 +293,7 @@ class _FormW4PageState extends State<FormW4Page> {
Row( Row(
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onTap: () => Modular.to.pop(), onTap: () => Modular.to.popSafe(),
child: const Icon( child: const Icon(
UiIcons.arrowLeft, UiIcons.arrowLeft,
color: UiColors.white, color: UiColors.white,
@@ -278,10 +304,7 @@ class _FormW4PageState extends State<FormW4Page> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(i18n.title, style: UiTypography.headline4m.white),
i18n.title,
style: UiTypography.headline4m.white,
),
Text( Text(
i18n.subtitle, i18n.subtitle,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
@@ -294,10 +317,9 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Row( Row(
children: steps children: steps.asMap().entries.map((
.asMap() MapEntry<int, Map<String, String>> entry,
.entries ) {
.map((MapEntry<int, Map<String, String>> entry) {
final int idx = entry.key; final int idx = entry.key;
final bool isLast = idx == steps.length - 1; final bool isLast = idx == steps.length - 1;
return Expanded( return Expanded(
@@ -434,7 +456,8 @@ class _FormW4PageState extends State<FormW4Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.first_name, i18n.fields.first_name,
value: state.firstName, value: state.firstName,
onChanged: (String val) => context.read<FormW4Cubit>().firstNameChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().firstNameChanged(val),
placeholder: i18n.fields.placeholder_john, placeholder: i18n.fields.placeholder_john,
), ),
), ),
@@ -443,7 +466,8 @@ class _FormW4PageState extends State<FormW4Page> {
child: _buildTextField( child: _buildTextField(
i18n.fields.last_name, i18n.fields.last_name,
value: state.lastName, value: state.lastName,
onChanged: (String val) => context.read<FormW4Cubit>().lastNameChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().lastNameChanged(val),
placeholder: i18n.fields.placeholder_smith, placeholder: i18n.fields.placeholder_smith,
), ),
), ),
@@ -465,14 +489,16 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField( _buildTextField(
i18n.fields.address, i18n.fields.address,
value: state.address, value: state.address,
onChanged: (String val) => context.read<FormW4Cubit>().addressChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().addressChanged(val),
placeholder: i18n.fields.placeholder_address, placeholder: i18n.fields.placeholder_address,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildTextField( _buildTextField(
i18n.fields.city_state_zip, i18n.fields.city_state_zip,
value: state.cityStateZip, value: state.cityStateZip,
onChanged: (String val) => context.read<FormW4Cubit>().cityStateZipChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().cityStateZipChanged(val),
placeholder: i18n.fields.placeholder_csz, placeholder: i18n.fields.placeholder_csz,
), ),
], ],
@@ -506,21 +532,9 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
_buildRadioOption( _buildRadioOption(context, state, 'SINGLE', i18n.fields.single, null),
context,
state,
'SINGLE',
i18n.fields.single,
null,
),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildRadioOption( _buildRadioOption(context, state, 'MARRIED', i18n.fields.married, null),
context,
state,
'MARRIED',
i18n.fields.married,
null,
),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildRadioOption( _buildRadioOption(
context, context,
@@ -573,16 +587,10 @@ class _FormW4PageState extends State<FormW4Page> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(label, style: UiTypography.body2m.textPrimary),
label,
style: UiTypography.body2m.textPrimary,
),
if (subLabel != null) ...<Widget>[ if (subLabel != null) ...<Widget>[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(subLabel, style: UiTypography.body3r.textSecondary),
subLabel,
style: UiTypography.body3r.textSecondary,
),
], ],
], ],
), ),
@@ -609,11 +617,7 @@ class _FormW4PageState extends State<FormW4Page> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
const Icon( const Icon(UiIcons.help, color: UiColors.accent, size: 20),
UiIcons.help,
color: UiColors.accent,
size: 20,
),
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Column( child: Column(
@@ -636,8 +640,9 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
GestureDetector( GestureDetector(
onTap: () => onTap: () => context.read<FormW4Cubit>().multipleJobsChanged(
context.read<FormW4Cubit>().multipleJobsChanged(!state.multipleJobs), !state.multipleJobs,
),
child: Container( child: Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -654,10 +659,14 @@ class _FormW4PageState extends State<FormW4Page> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: state.multipleJobs ? UiColors.primary : UiColors.bgPopup, color: state.multipleJobs
? UiColors.primary
: UiColors.bgPopup,
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
border: Border.all( border: Border.all(
color: state.multipleJobs ? UiColors.primary : UiColors.border, color: state.multipleJobs
? UiColors.primary
: UiColors.border,
), ),
), ),
child: state.multipleJobs child: state.multipleJobs
@@ -741,7 +750,8 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.children_under_17, i18n.fields.children_under_17,
i18n.fields.children_each, i18n.fields.children_each,
(FormW4State s) => s.qualifyingChildren, (FormW4State s) => s.qualifyingChildren,
(int val) => context.read<FormW4Cubit>().qualifyingChildrenChanged(val), (int val) =>
context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
), ),
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
@@ -753,7 +763,8 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.other_dependents, i18n.fields.other_dependents,
i18n.fields.other_each, i18n.fields.other_each,
(FormW4State s) => s.otherDependents, (FormW4State s) => s.otherDependents,
(int val) => context.read<FormW4Cubit>().otherDependentsChanged(val), (int val) =>
context.read<FormW4Cubit>().otherDependentsChanged(val),
), ),
], ],
), ),
@@ -775,9 +786,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
Text( Text(
'\$${_totalCredits(state)}', '\$${_totalCredits(state)}',
style: UiTypography.body2b.textSuccess.copyWith( style: UiTypography.body2b.textSuccess.copyWith(fontSize: 18),
fontSize: 18,
),
), ),
], ],
), ),
@@ -802,22 +811,14 @@ class _FormW4PageState extends State<FormW4Page> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(child: Text(label, style: UiTypography.body2m)),
child: Text(
label,
style: UiTypography.body2m,
),
),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.tagSuccess, color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
), ),
child: Text( child: Text(badge, style: UiTypography.footnote2b.textSuccess),
badge,
style: UiTypography.footnote2b.textSuccess,
),
), ),
], ],
), ),
@@ -839,10 +840,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
), ),
), ),
_buildCircleBtn( _buildCircleBtn(UiIcons.add, () => onChanged(value + 1)),
UiIcons.add,
() => onChanged(value + 1),
),
], ],
), ),
], ],
@@ -881,7 +879,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField( _buildTextField(
i18n.fields.other_income, i18n.fields.other_income,
value: state.otherIncome, value: state.otherIncome,
onChanged: (String val) => context.read<FormW4Cubit>().otherIncomeChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().otherIncomeChanged(val),
placeholder: i18n.fields.hints.zero, placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
@@ -896,7 +895,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField( _buildTextField(
i18n.fields.deductions, i18n.fields.deductions,
value: state.deductions, value: state.deductions,
onChanged: (String val) => context.read<FormW4Cubit>().deductionsChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().deductionsChanged(val),
placeholder: i18n.fields.hints.zero, placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
@@ -911,7 +911,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField( _buildTextField(
i18n.fields.extra_withholding, i18n.fields.extra_withholding,
value: state.extraWithholding, value: state.extraWithholding,
onChanged: (String val) => context.read<FormW4Cubit>().extraWithholdingChanged(val), onChanged: (String val) =>
context.read<FormW4Cubit>().extraWithholdingChanged(val),
placeholder: i18n.fields.hints.zero, placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
@@ -1019,10 +1020,7 @@ class _FormW4PageState extends State<FormW4Page> {
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18), style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
i18n.fields.date_label,
style: UiTypography.body3m.textSecondary,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -1050,10 +1048,7 @@ class _FormW4PageState extends State<FormW4Page> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(label, style: UiTypography.body2r.textSecondary),
label,
style: UiTypography.body2r.textSecondary,
),
Text( Text(
value, value,
style: UiTypography.body2m.copyWith( style: UiTypography.body2m.copyWith(
@@ -1066,7 +1061,9 @@ class _FormW4PageState extends State<FormW4Page> {
} }
String _getFilingStatusLabel(String status) { String _getFilingStatusLabel(String status) {
final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields; final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(
context,
).staff_compliance.tax_forms.w4.fields;
switch (status) { switch (status) {
case 'SINGLE': case 'SINGLE':
return i18n.status_single; return i18n.status_single;
@@ -1084,7 +1081,9 @@ class _FormW4PageState extends State<FormW4Page> {
FormW4State state, FormW4State state,
List<Map<String, String>> steps, List<Map<String, String>> steps,
) { ) {
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(
context,
).staff_compliance.tax_forms.w4;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
@@ -1131,8 +1130,8 @@ class _FormW4PageState extends State<FormW4Page> {
Expanded( Expanded(
flex: 2, flex: 2,
child: ElevatedButton( child: ElevatedButton(
onPressed: ( onPressed:
_canProceed(state) && (_canProceed(state) &&
state.status != FormW4Status.submitting) state.status != FormW4Status.submitting)
? () => _handleNext(context, state.currentStep) ? () => _handleNext(context, state.currentStep)
: null, : null,
@@ -1167,7 +1166,11 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
if (state.currentStep < steps.length - 1) ...<Widget>[ if (state.currentStep < steps.length - 1) ...<Widget>[
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), const Icon(
UiIcons.arrowRight,
size: 16,
color: UiColors.white,
),
], ],
], ],
), ),
@@ -1179,5 +1182,3 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/tax_forms/tax_forms_cubit.dart'; import '../blocs/tax_forms/tax_forms_cubit.dart';
import '../blocs/tax_forms/tax_forms_state.dart'; import '../blocs/tax_forms/tax_forms_state.dart';
@@ -13,39 +14,10 @@ class TaxFormsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: const UiAppBar(
backgroundColor: UiColors.primary, title: 'Tax Documents',
elevation: 0, subtitle: 'Complete required forms to start working',
leading: IconButton( showBackButton: true,
icon: const Icon(UiIcons.arrowLeft, color: UiColors.bgPopup),
onPressed: () => Modular.to.pop(),
),
title: Text(
'Tax Documents',
style: UiTypography.headline3m.textSecondary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Padding(
padding: const EdgeInsets.only(
left: UiConstants.space5,
right: UiConstants.space5,
bottom: UiConstants.space5,
),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'Complete required forms to start working',
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.8),
),
),
),
],
),
),
),
), ),
body: BlocProvider<TaxFormsCubit>( body: BlocProvider<TaxFormsCubit>(
create: (BuildContext context) { create: (BuildContext context) {
@@ -64,7 +36,9 @@ class TaxFormsPage extends StatelessWidget {
if (state.status == TaxFormsStatus.failure) { if (state.status == TaxFormsStatus.failure) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Text( child: Text(
state.errorMessage != null state.errorMessage != null
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
@@ -81,10 +55,12 @@ class TaxFormsPage extends StatelessWidget {
vertical: UiConstants.space6, vertical: UiConstants.space6,
), ),
child: Column( child: Column(
spacing: UiConstants.space6, spacing: UiConstants.space4,
children: <Widget>[ children: <Widget>[
_buildProgressOverview(state.forms), _buildProgressOverview(state.forms),
...state.forms.map((TaxForm form) => _buildFormCard(context, form)), ...state.forms.map(
(TaxForm form) => _buildFormCard(context, form),
),
_buildInfoCard(), _buildInfoCard(),
], ],
), ),
@@ -118,10 +94,7 @@ class TaxFormsPage extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text('Document Progress', style: UiTypography.body2m.textPrimary),
'Document Progress',
style: UiTypography.body2m.textPrimary,
),
Text( Text(
'$completedCount/$totalCount', '$completedCount/$totalCount',
style: UiTypography.body2m.textSecondary, style: UiTypography.body2m.textSecondary,
@@ -150,12 +123,18 @@ class TaxFormsPage extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () async { onTap: () async {
if (form is I9TaxForm) { if (form is I9TaxForm) {
final Object? result = await Modular.to.pushNamed('i9', arguments: form); final Object? result = await Modular.to.pushNamed(
'i9',
arguments: form,
);
if (result == true && context.mounted) { if (result == true && context.mounted) {
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms(); await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
} }
} else if (form is W4TaxForm) { } else if (form is W4TaxForm) {
final Object? result = await Modular.to.pushNamed('w4', arguments: form); final Object? result = await Modular.to.pushNamed(
'w4',
arguments: form,
);
if (result == true && context.mounted) { if (result == true && context.mounted) {
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms(); await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
} }
@@ -245,10 +224,7 @@ class TaxFormsPage extends StatelessWidget {
color: UiColors.textSuccess, color: UiColors.textSuccess,
), ),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Text( Text('Completed', style: UiTypography.footnote2b.textSuccess),
'Completed',
style: UiTypography.footnote2b.textSuccess,
),
], ],
), ),
); );
@@ -267,10 +243,7 @@ class TaxFormsPage extends StatelessWidget {
children: <Widget>[ children: <Widget>[
const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning), const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Text( Text('In Progress', style: UiTypography.footnote2b.textWarning),
'In Progress',
style: UiTypography.footnote2b.textWarning,
),
], ],
), ),
); );

View File

@@ -1,14 +1,16 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart'; import 'package:krow_core/core.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
// ignore: depend_on_referenced_packages
import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_cubit.dart';
import '../blocs/bank_account_state.dart'; import '../blocs/bank_account_state.dart';
import '../widgets/account_card.dart';
import '../widgets/add_account_form.dart'; import '../widgets/add_account_form.dart';
import '../widgets/security_notice.dart';
class BankAccountPage extends StatelessWidget { class BankAccountPage extends StatelessWidget {
const BankAccountPage({super.key}); const BankAccountPage({super.key});
@@ -26,23 +28,7 @@ class BankAccountPage extends StatelessWidget {
final dynamic strings = t.staff.profile.bank_account_page; final dynamic strings = t.staff.profile.bank_account_page;
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, appBar: UiAppBar(title: strings.title, showBackButton: true),
appBar: AppBar(
backgroundColor: UiColors.background, // Was surface
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.pop(),
),
title: Text(
strings.title,
style: UiTypography.headline3m.textPrimary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocConsumer<BankAccountCubit, BankAccountState>( body: BlocConsumer<BankAccountCubit, BankAccountState>(
bloc: cubit, bloc: cubit,
listener: (BuildContext context, BankAccountState state) { listener: (BuildContext context, BankAccountState state) {
@@ -61,7 +47,8 @@ class BankAccountPage extends StatelessWidget {
// Error is already shown on the page itself (lines 73-85), no need for snackbar // Error is already shown on the page itself (lines 73-85), no need for snackbar
}, },
builder: (BuildContext context, BankAccountState state) { builder: (BuildContext context, BankAccountState state) {
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) { if (state.status == BankAccountStatus.loading &&
state.accounts.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -74,7 +61,9 @@ class BankAccountPage extends StatelessWidget {
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
: 'Error', : 'Error',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), style: UiTypography.body1m.copyWith(
color: UiColors.textSecondary,
),
), ),
), ),
); );
@@ -88,15 +77,22 @@ class BankAccountPage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
_buildSecurityNotice(strings), SecurityNotice(strings: strings),
const SizedBox(height: UiConstants.space6), if (state.accounts.isEmpty) ...<Widget>[
Text( const SizedBox(height: UiConstants.space32),
strings.linked_accounts, const UiEmptyState(
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), icon: UiIcons.building,
title: 'No accounts yet',
description:
'Add your first bank account to get started',
), ),
const SizedBox(height: UiConstants.space3), ] else ...<Widget>[
...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type const SizedBox(height: UiConstants.space4),
...state.accounts.map<Widget>(
(StaffBankAccount account) =>
AccountCard(account: account, strings: strings),
),
],
// Add extra padding at bottom // Add extra padding at bottom
const SizedBox(height: UiConstants.space20), const SizedBox(height: UiConstants.space20),
], ],
@@ -121,17 +117,23 @@ class BankAccountPage extends StatelessWidget {
backgroundColor: UiColors.transparent, backgroundColor: UiColors.transparent,
child: AddAccountForm( child: AddAccountForm(
strings: strings, strings: strings,
onSubmit: (String bankName, String routing, String account, String type) { onSubmit:
(
String bankName,
String routing,
String account,
String type,
) {
cubit.addAccount( cubit.addAccount(
bankName: bankName, bankName: bankName,
routingNumber: routing, routingNumber: routing,
accountNumber: account, accountNumber: account,
type: type, type: type,
); );
Modular.to.pop(); Modular.to.popSafe();
}, },
onCancel: () { onCancel: () {
Modular.to.pop(); Modular.to.popSafe();
}, },
), ),
); );
@@ -148,118 +150,4 @@ class BankAccountPage extends StatelessWidget {
), ),
); );
} }
Widget _buildSecurityNotice(dynamic strings) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.shield, color: UiColors.primary, size: 20),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
strings.secure_title,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space1 - 2), // 2px
Text(
strings.secure_subtitle,
style: UiTypography.body3r.textSecondary,
),
],
),
),
],
),
);
}
Widget _buildAccountCard(StaffBankAccount account, dynamic strings) {
final bool isPrimary = account.isPrimary;
const Color primaryColor = UiColors.primary;
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup, // Was surface, using bgPopup (white) for card
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: isPrimary ? primaryColor : UiColors.border,
width: isPrimary ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(
UiIcons.building,
color: primaryColor,
size: UiConstants.iconLg,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
account.bankName,
style: UiTypography.body2m.textPrimary,
),
Text(
strings.account_ending(
last4: account.last4?.isNotEmpty == true
? account.last4!
: '----',
),
style: UiTypography.body2r.textSecondary,
),
],
),
],
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.15),
borderRadius: UiConstants.radiusFull,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.check, size: UiConstants.iconXs, color: primaryColor),
const SizedBox(width: UiConstants.space1),
Text(
strings.primary,
style: UiTypography.body3m.primary,
),
],
),
),
],
),
);
}
} }

View File

@@ -0,0 +1,97 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
class AccountCard extends StatelessWidget {
final StaffBankAccount account;
final dynamic strings;
const AccountCard({
super.key,
required this.account,
required this.strings,
});
@override
Widget build(BuildContext context) {
final bool isPrimary = account.isPrimary;
const Color primaryColor = UiColors.primary;
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: isPrimary ? primaryColor : UiColors.border,
width: isPrimary ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(
UiIcons.building,
color: primaryColor,
size: UiConstants.iconLg,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
account.bankName,
style: UiTypography.body2m.textPrimary,
),
Text(
strings.account_ending(
last4: account.last4?.isNotEmpty == true
? account.last4!
: '----',
),
style: UiTypography.body2r.textSecondary,
),
],
),
],
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.15),
borderRadius: UiConstants.radiusFull,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.check,
size: UiConstants.iconXs,
color: primaryColor,
),
const SizedBox(width: UiConstants.space1),
Text(strings.primary, style: UiTypography.body3m.primary),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
class SecurityNotice extends StatelessWidget {
final dynamic strings;
const SecurityNotice({
super.key,
required this.strings,
});
@override
Widget build(BuildContext context) {
return UiNoticeBanner(
icon: UiIcons.shield,
title: strings.secure_title,
description: strings.secure_subtitle,
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:krow_core/core.dart';
import '../blocs/time_card_bloc.dart'; import '../blocs/time_card_bloc.dart';
import '../widgets/month_selector.dart'; import '../widgets/month_selector.dart';
import '../widgets/shift_history_list.dart'; import '../widgets/shift_history_list.dart';
@@ -18,11 +19,12 @@ class TimeCardPage extends StatefulWidget {
} }
class _TimeCardPageState extends State<TimeCardPage> { class _TimeCardPageState extends State<TimeCardPage> {
final TimeCardBloc _bloc = Modular.get<TimeCardBloc>(); late final TimeCardBloc _bloc;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bloc = Modular.get<TimeCardBloc>();
_bloc.add(LoadTimeCards(DateTime.now())); _bloc.add(LoadTimeCards(DateTime.now()));
} }
@@ -32,22 +34,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: Scaffold( child: Scaffold(
backgroundColor: UiColors.bgPrimary, appBar: UiAppBar(
appBar: AppBar( title: t.staff_time_card.title,
backgroundColor: UiColors.bgPopup, showBackButton: true,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
onPressed: () => Modular.to.pop(),
),
title: Text(
t.staff_time_card.title,
style: UiTypography.headline4m.textPrimary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
), ),
body: BlocConsumer<TimeCardBloc, TimeCardState>( body: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (BuildContext context, TimeCardState state) { listener: (BuildContext context, TimeCardState state) {
@@ -69,7 +58,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
child: Text( child: Text(
translateErrorKey(state.message), translateErrorKey(state.message),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), style: UiTypography.body1m.copyWith(
color: UiColors.textSecondary,
),
), ),
), ),
); );
@@ -83,12 +74,22 @@ class _TimeCardPageState extends State<TimeCardPage> {
children: <Widget>[ children: <Widget>[
MonthSelector( MonthSelector(
selectedDate: state.selectedMonth, selectedDate: state.selectedMonth,
onPreviousMonth: () => _bloc.add(ChangeMonth( onPreviousMonth: () => _bloc.add(
DateTime(state.selectedMonth.year, state.selectedMonth.month - 1), ChangeMonth(
)), DateTime(
onNextMonth: () => _bloc.add(ChangeMonth( state.selectedMonth.year,
DateTime(state.selectedMonth.year, state.selectedMonth.month + 1), state.selectedMonth.month - 1,
)), ),
),
),
onNextMonth: () => _bloc.add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month + 1,
),
),
),
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
TimeCardSummary( TimeCardSummary(
@@ -108,4 +109,3 @@ class _TimeCardPageState extends State<TimeCardPage> {
); );
} }
} }

View File

@@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart';
import '../widgets/attire_capture_page/file_types_banner.dart';
import '../widgets/attire_capture_page/footer_section.dart'; import '../widgets/attire_capture_page/footer_section.dart';
import '../widgets/attire_capture_page/image_preview_section.dart'; import '../widgets/attire_capture_page/image_preview_section.dart';
import '../widgets/attire_capture_page/info_section.dart'; import '../widgets/attire_capture_page/info_section.dart';
@@ -135,7 +136,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
leading: const Icon(Icons.photo_library), leading: const Icon(Icons.photo_library),
title: Text(t.common.gallery), title: Text(t.common.gallery),
onTap: () { onTap: () {
Modular.to.pop(); Modular.to.popSafe();
_onGallery(context); _onGallery(context);
}, },
), ),
@@ -143,7 +144,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
leading: const Icon(Icons.camera_alt), leading: const Icon(Icons.camera_alt),
title: Text(t.common.camera), title: Text(t.common.camera),
onTap: () { onTap: () {
Modular.to.pop(); Modular.to.popSafe();
_onCamera(context); _onCamera(context);
}, },
), ),
@@ -215,10 +216,16 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
String _getStatusText(bool hasUploadedPhoto) { String _getStatusText(bool hasUploadedPhoto) {
return switch (widget.item.verificationStatus) { return switch (widget.item.verificationStatus) {
AttireVerificationStatus.approved => t.staff_profile_attire.capture.approved, AttireVerificationStatus.approved =>
AttireVerificationStatus.rejected => t.staff_profile_attire.capture.rejected, t.staff_profile_attire.capture.approved,
AttireVerificationStatus.pending => t.staff_profile_attire.capture.pending_verification, AttireVerificationStatus.rejected =>
_ => hasUploadedPhoto ? t.staff_profile_attire.capture.pending_verification : t.staff_profile_attire.capture.not_uploaded, t.staff_profile_attire.capture.rejected,
AttireVerificationStatus.pending =>
t.staff_profile_attire.capture.pending_verification,
_ =>
hasUploadedPhoto
? t.staff_profile_attire.capture.pending_verification
: t.staff_profile_attire.capture.not_uploaded,
}; };
} }
@@ -280,8 +287,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
_FileTypesBanner( FileTypesBanner(
message: t.staff_profile_attire.upload_file_types_banner, message: t
.staff_profile_attire
.upload_file_types_banner,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
ImagePreviewSection( ImagePreviewSection(
@@ -327,43 +336,3 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
); );
} }
} }
/// Banner displaying accepted file types and size limit for attire upload.
class _FileTypesBanner extends StatelessWidget {
const _FileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.tagActive,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(
UiIcons.info,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
message,
style: UiTypography.body2r.textSecondary,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for attire upload.
class FileTypesBanner extends StatelessWidget {
/// Creates a [FileTypesBanner].
const FileTypesBanner({super.key, required this.message});
/// The message to display in the banner.
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.primary.withAlpha(20),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(message, style: UiTypography.body2r.textSecondary),
),
],
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:krow_core/core.dart';
import 'attire_upload_buttons.dart'; import 'attire_upload_buttons.dart';
@@ -98,7 +99,7 @@ class FooterSection extends StatelessWidget {
text: 'Submit Image', text: 'Submit Image',
onPressed: () { onPressed: () {
if (updatedItem != null) { if (updatedItem != null) {
Modular.to.pop(updatedItem); Modular.to.popSafe(updatedItem);
} }
}, },
), ),

View File

@@ -7,35 +7,10 @@ class AttireInfoCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return UiNoticeBanner(
padding: const EdgeInsets.all(UiConstants.space4), icon: UiIcons.shirt,
decoration: BoxDecoration( title: t.staff_profile_attire.info_card.title,
color: UiColors.primary.withValues(alpha: 0.08), description: t.staff_profile_attire.info_card.description,
borderRadius: UiConstants.radiusLg,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.shirt, color: UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_profile_attire.info_card.title,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: 2),
Text(
t.staff_profile_attire.info_card.description,
style: UiTypography.body2r.textSecondary,
),
],
),
),
],
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/emergency_contact_bloc.dart'; import '../blocs/emergency_contact_bloc.dart';
import '../widgets/emergency_contact_add_button.dart'; import '../widgets/emergency_contact_add_button.dart';
import '../widgets/emergency_contact_form_item.dart'; import '../widgets/emergency_contact_form_item.dart';
@@ -21,22 +22,11 @@ class EmergencyContactScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Translations.of(context); // Force rebuild on locale change Translations.of(context); // Force rebuild on locale change
return Scaffold( return Scaffold(
appBar: AppBar( appBar: UiAppBar(
elevation: 0, title: 'Emergency Contact',
leading: IconButton( showBackButton: true,
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.pop(),
), ),
title: Text( body: BlocProvider<EmergencyContactBloc>(
'Emergency Contact',
style: UiTypography.title1m.textPrimary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocProvider(
create: (context) => Modular.get<EmergencyContactBloc>(), create: (context) => Modular.get<EmergencyContactBloc>(),
child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>( child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>(
listener: (context, state) { listener: (context, state) {

View File

@@ -6,16 +6,9 @@ class EmergencyContactInfoBanner extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return UiNoticeBanner(
padding: const EdgeInsets.all(UiConstants.space4), title:
decoration: BoxDecoration(
color: UiColors.accent.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusLg,
),
child: Text(
'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.', 'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.',
style: UiTypography.body2r.textPrimary,
),
); );
} }
} }

Some files were not shown because too many files have changed in this diff Show More