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
.gradle/
**/android/app/libs/
**/android/key.properties
**/android/local.properties
# Build outputs
@@ -193,3 +192,4 @@ AGENTS.md
CLAUDE.md
GEMINI.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
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:
```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.
### 3. Running the Apps
### 4. Running the Apps
You can run the applications using Melos scripts or through the `Makefile`:
First, find your device ID:

View File

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

View File

@@ -1,4 +1,5 @@
import java.util.Base64
import java.util.Properties
plugins {
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 {
namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion
@@ -44,14 +52,32 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
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 {
release {
// TODO: Add your own signing config for the release build.
// 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": [
{
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com",
"client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.krowwithus.client",
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280"
"certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
}
},
{
@@ -130,11 +130,11 @@
},
"oauth_client": [
{
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
"client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"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.
class AppModule extends Module {
@override
List<Module> get imports => <Module>[core_localization.LocalizationModule()];
List<Module> get imports => <Module>[
core_localization.LocalizationModule(),
CoreModule(),
];
@override
void routes(RouteManager r) {
@@ -99,8 +102,10 @@ class AppWidget extends StatelessWidget {
>(
builder:
(BuildContext context, core_localization.LocaleState state) {
return core_localization.TranslationProvider(
child: MaterialApp.router(
return KeyedSubtree(
key: ValueKey<Locale>(state.locale),
child: core_localization.TranslationProvider(
child: MaterialApp.router(
debugShowCheckedModeBanner: false,
title: "KROW Client",
theme: UiTheme.light,
@@ -114,6 +119,7 @@ class AppWidget extends StatelessWidget {
GlobalCupertinoLocalizations.delegate,
],
),
),
);
},
),

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import java.util.Base64
import java.util.Properties
plugins {
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 {
namespace = "com.krowwithus.staff"
compileSdk = flutter.compileSdkVersion
@@ -44,14 +52,33 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
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 {
debug {
signingConfig = signingConfigs.getByName("release")
}
release {
// TODO: Add your own signing config for the release build.
// 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": [
{
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com",
"client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.krowwithus.client",
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280"
"certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
}
},
{
@@ -130,11 +130,11 @@
},
"oauth_client": [
{
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
"client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.krowwithus.staff",
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d"
"certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153"
}
},
{
@@ -167,4 +167,4 @@
}
],
"configuration_version": "1"
}
}

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

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../navigation_extensions.dart';
import 'route_paths.dart';
/// 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.
/// Used when signing out or session expires.
void toClientRoot() {
navigate(ClientPaths.root);
safeNavigate(ClientPaths.root);
}
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toClientGetStartedPage() {
navigate(ClientPaths.getStarted);
safeNavigate(ClientPaths.getStarted);
}
/// 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
/// or social authentication providers.
void toClientSignIn() {
pushNamed(ClientPaths.signIn);
safePush(ClientPaths.signIn);
}
/// 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
/// the initial registration form.
void toClientSignUp() {
pushNamed(ClientPaths.signUp);
safePush(ClientPaths.signUp);
}
/// Navigates to the client home dashboard.
@@ -66,7 +67,7 @@ extension ClientNavigator on IModularNavigator {
///
/// Uses pushNamed to avoid trailing slash issues with navigate().
void toClientHome() {
navigate(ClientPaths.home);
safeNavigate(ClientPaths.home);
}
/// 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
/// to a specific tab instead (like [toClientHome]).
void toClientMain() {
navigate(ClientPaths.main);
safeNavigate(ClientPaths.main);
}
// ==========================================================================
@@ -85,43 +86,43 @@ extension ClientNavigator on IModularNavigator {
///
/// Displays workforce coverage analytics and metrics.
void toClientCoverage() {
navigate(ClientPaths.coverage);
safeNavigate(ClientPaths.coverage);
}
/// Navigates to the Billing tab.
///
/// Access billing history, invoices, and payment methods.
void toClientBilling() {
navigate(ClientPaths.billing);
safeNavigate(ClientPaths.billing);
}
/// Navigates to the Completion Review page.
void toCompletionReview({Object? arguments}) {
pushNamed(ClientPaths.completionReview, arguments: arguments);
safePush(ClientPaths.completionReview, arguments: arguments);
}
/// Navigates to the full list of invoices awaiting approval.
void toAwaitingApproval({Object? arguments}) {
pushNamed(ClientPaths.awaitingApproval, arguments: arguments);
Future<Object?> toAwaitingApproval({Object? arguments}) {
return safePush(ClientPaths.awaitingApproval, arguments: arguments);
}
/// Navigates to the Invoice Ready page.
void toInvoiceReady() {
pushNamed(ClientPaths.invoiceReady);
safePush(ClientPaths.invoiceReady);
}
/// Navigates to the Orders tab.
///
/// View and manage all shift orders with filtering and sorting.
void toClientOrders() {
navigate(ClientPaths.orders);
safeNavigate(ClientPaths.orders);
}
/// Navigates to the Reports tab.
///
/// Generate and view workforce reports and analytics.
void toClientReports() {
navigate(ClientPaths.reports);
safeNavigate(ClientPaths.reports);
}
// ==========================================================================
@@ -132,12 +133,12 @@ extension ClientNavigator on IModularNavigator {
///
/// Manage account settings, notifications, and app preferences.
void toClientSettings() {
pushNamed(ClientPaths.settings);
safePush(ClientPaths.settings);
}
/// Pushes the edit profile page.
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.
Future<void> toClientHubs() async {
await pushNamed(ClientPaths.hubs);
await safePush(ClientPaths.hubs);
}
/// Navigates to the details of a specific hub.
Future<bool?> toHubDetails(Hub hub) {
return pushNamed<bool?>(
return safePush<bool?>(
ClientPaths.hubDetails,
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.
Future<bool?> toEditHub({Hub? hub}) async {
return pushNamed<bool?>(
return safePush<bool?>(
ClientPaths.editHub,
arguments: <String, dynamic>{'hub': hub},
// 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.
void toCreateOrder({Object? arguments}) {
navigate(ClientPaths.createOrder, arguments: arguments);
safeNavigate(ClientPaths.createOrder, arguments: arguments);
}
/// Pushes the rapid order creation flow.
///
/// Quick shift creation with simplified inputs for urgent needs.
void toCreateOrderRapid({Object? arguments}) {
pushNamed(ClientPaths.createOrderRapid, arguments: arguments);
safePush(ClientPaths.createOrderRapid, arguments: arguments);
}
/// Pushes the one-time order creation flow.
///
/// Create a shift that occurs once at a specific date and time.
void toCreateOrderOneTime({Object? arguments}) {
pushNamed(ClientPaths.createOrderOneTime, arguments: arguments);
safePush(ClientPaths.createOrderOneTime, arguments: arguments);
}
/// Pushes the recurring order creation flow.
///
/// Create shifts that repeat on a defined schedule (daily, weekly, etc.).
void toCreateOrderRecurring({Object? arguments}) {
pushNamed(ClientPaths.createOrderRecurring, arguments: arguments);
safePush(ClientPaths.createOrderRecurring, arguments: arguments);
}
/// Pushes the permanent order creation flow.
///
/// Create a long-term or permanent staffing position.
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.
void toOrdersSpecificDate(DateTime date) {
navigate(
safeNavigate(
ClientPaths.orders,
arguments: <String, DateTime>{'initialDate': date},
);

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'client/route_paths.dart';
import 'staff/route_paths.dart';
/// Base navigation utilities extension for [IModularNavigator].
///
@@ -21,17 +24,15 @@ extension NavigationExtensions on IModularNavigator {
/// * [arguments] - Optional arguments to pass to the route
///
/// Returns `true` if navigation was successful, `false` otherwise.
Future<bool> safeNavigate(
String path, {
Object? arguments,
}) async {
Future<bool> safeNavigate(String path, {Object? arguments}) async {
try {
navigate(path, arguments: arguments);
return true;
} catch (e) {
// In production, you might want to log this to a monitoring service
// ignore: avoid_print
print('Navigation error to $path: $e');
// ignore: avoid_debugPrint
debugPrint('Navigation error to $path: $e');
navigateToHome();
return false;
}
}
@@ -54,8 +55,30 @@ extension NavigationExtensions on IModularNavigator {
return await pushNamed<T>(routeName, arguments: arguments);
} catch (e) {
// In production, you might want to log this to a monitoring service
// ignore: avoid_print
print('Push navigation error to $routeName: $e');
// ignore: avoid_debugPrint
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;
}
}
@@ -68,14 +91,31 @@ extension NavigationExtensions on IModularNavigator {
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.
bool popSafe() {
/// Returns `true` if a route was popped, `false` if it navigated to home.
bool popSafe<T extends Object?>([T? result]) {
if (canPop()) {
pop();
pop(result);
return true;
}
navigateToHome();
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:krow_domain/krow_domain.dart';
import '../navigation_extensions.dart';
import 'route_paths.dart';
/// 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.
/// Used when signing out or session expires.
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() {
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) {
pushNamed(
safePush(
StaffPaths.phoneVerification,
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() {
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() {
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
safePushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
}
/// Navigates to the benefits overview page.
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() {
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({
DateTime? selectedDate,
String? initialTab,
@@ -118,94 +79,47 @@ extension StaffNavigator on IModularNavigator {
if (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() {
pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false);
safePushNamedAndRemoveUntil(StaffPaths.payments, (_) => false);
}
/// Navigates to the Clock In tab.
///
/// Access time tracking interface for active shifts.
void toClockIn() {
pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false);
safePushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false);
}
/// Navigates to the Profile tab.
///
/// Manage personal information, documents, and preferences.
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) {
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() {
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() {
pushNamed(StaffPaths.preferredLocations);
safePush(StaffPaths.preferredLocations);
}
/// Pushes the emergency contact page.
///
/// Manage emergency contact details for safety purposes.
void toEmergencyContact() {
pushNamed(StaffPaths.emergencyContact);
safePush(StaffPaths.emergencyContact);
}
/// Pushes the work experience page.
///
/// Record previous work experience and qualifications.
void toExperience() {
navigate(StaffPaths.experience);
safeNavigate(StaffPaths.experience);
}
/// Pushes the attire preferences page.
///
/// Record sizing and appearance information for uniform allocation.
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}) {
navigate(
safeNavigate(
StaffPaths.attireCapture,
arguments: <String, dynamic>{
'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() {
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}) {
navigate(
safeNavigate(
StaffPaths.documentUpload,
arguments: <String, dynamic>{
'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() {
pushNamed(StaffPaths.certificates);
safePush(StaffPaths.certificates);
}
// ==========================================================================
// FINANCIAL INFORMATION
// ==========================================================================
/// Pushes the bank account information page.
///
/// Manage banking details for direct deposit payments.
void toBankAccount() {
pushNamed(StaffPaths.bankAccount);
safePush(StaffPaths.bankAccount);
}
/// Pushes the tax forms page.
///
/// Manage W-4, tax withholding, and related tax documents.
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() {
pushNamed(StaffPaths.timeCard);
safePush(StaffPaths.timeCard);
}
// ==========================================================================
// SCHEDULING & AVAILABILITY
// ==========================================================================
/// Pushes the availability management page.
///
/// Define when the staff member is available to work.
void toAvailability() {
pushNamed(StaffPaths.availability);
safePush(StaffPaths.availability);
}
// ==========================================================================
// ADDITIONAL FEATURES
// ==========================================================================
/// Pushes the KROW University page (placeholder).
///
/// Access training materials and educational courses.
void toKrowUniversity() {
pushNamed(StaffPaths.krowUniversity);
safePush(StaffPaths.krowUniversity);
}
/// Pushes the trainings page (placeholder).
///
/// View and complete required training modules.
void toTrainings() {
pushNamed(StaffPaths.trainings);
safePush(StaffPaths.trainings);
}
/// Pushes the leaderboard page (placeholder).
///
/// View performance rankings and achievements.
void toLeaderboard() {
pushNamed(StaffPaths.leaderboard);
safePush(StaffPaths.leaderboard);
}
/// Pushes the FAQs page.
///
/// Access frequently asked questions and help resources.
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() {
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() {
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() {
pushNamed(StaffPaths.privacyPolicy);
safePush(StaffPaths.privacyPolicy);
}
// ==========================================================================
// MESSAGING & COMMUNICATION
// ==========================================================================
/// Pushes the messages page (placeholder).
///
/// Access internal messaging system.
void toMessages() {
pushNamed(StaffPaths.messages);
safePush(StaffPaths.messages);
}
/// Pushes the settings page (placeholder).
///
/// General app settings and preferences.
void toSettings() {
pushNamed(StaffPaths.settings);
safePush(StaffPaths.settings);
}
}

View File

@@ -57,6 +57,9 @@ class LocaleBloc extends Bloc<LocaleEvent, LocaleState> {
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.

View File

@@ -21,6 +21,13 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface {
@override
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();
}

View File

@@ -349,6 +349,7 @@
"listening": "Listening...",
"send": "Send Message",
"sending": "Sending...",
"transcribing": "Transcribing...",
"success_title": "Request Sent!",
"success_message": "We're finding available workers for you right now. You'll be notified as they accept.",
"back_to_orders": "Back to Orders"
@@ -540,8 +541,8 @@
"min_break": "min break"
},
"actions": {
"approve_pay": "Approve & Process Payment",
"flag_review": "Flag for Review",
"approve_pay": "Approve",
"flag_review": "Review",
"download_pdf": "Download Invoice PDF"
},
"flag_dialog": {
@@ -1317,7 +1318,7 @@
},
"find_shifts": {
"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",
"search_hint": "Search jobs, location...",
"filter_all": "All Jobs",

View File

@@ -349,6 +349,7 @@
"listening": "Escuchando...",
"send": "Enviar Mensaje",
"sending": "Enviando...",
"transcribing": "Transcribiendo...",
"success_title": "\u00a1Solicitud Enviada!",
"success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.",
"back_to_orders": "Volver a \u00d3rdenes"
@@ -535,8 +536,8 @@
"min_break": "min de descanso"
},
"actions": {
"approve_pay": "Aprobar y Procesar Pago",
"flag_review": "Marcar para Revisi\u00f3n",
"approve_pay": "Aprobar",
"flag_review": "Revisi\u00f3n",
"download_pdf": "Descargar PDF de Factura"
},
"flag_dialog": {
@@ -1312,7 +1313,7 @@
},
"find_shifts": {
"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",
"search_hint": "Buscar trabajos, ubicaci\u00f3n...",
"filter_all": "Todos",

View File

@@ -6,16 +6,21 @@ import '../../domain/repositories/billing_connector_repository.dart';
/// Implementation of [BillingConnectorRepository].
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
BillingConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
BillingConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId}) async {
Future<List<BusinessBankAccount>> getBankAccounts({
required String businessId,
}) 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)
.execute();
@@ -26,21 +31,32 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
@override
Future<double> getCurrentBillAmount({required String businessId}) 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)
.execute();
return result.data.invoices
.map(_mapInvoice)
.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
Future<List<Invoice>> getInvoiceHistory({required String businessId}) 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)
.limit(20)
.execute();
@@ -55,14 +71,22 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
@override
Future<List<Invoice>> getPendingInvoices({required String businessId}) 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)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) =>
i.status != InvoiceStatus.paid)
.where(
(Invoice i) =>
i.status != InvoiceStatus.paid &&
i.status != InvoiceStatus.disputed &&
i.status != InvoiceStatus.open,
)
.toList();
});
}
@@ -76,19 +100,28 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
final DateTime now = DateTime.now();
final DateTime start;
final DateTime end;
if (period == BillingPeriod.week) {
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(now.year, now.month, now.day)
.subtract(Duration(days: daysFromMonday));
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
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 {
start = DateTime(now.year, now.month, 1);
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(
businessId: businessId,
start: _service.toTimestamp(start),
@@ -96,16 +129,18 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
)
.execute();
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles> shiftRoles = result.data.shiftRoles;
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) return <InvoiceItem>[];
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 roleName = role.role.name;
final double hours = role.hours ?? 0.0;
final double totalValue = role.totalValue ?? 0.0;
final _RoleSummary? existing = summary[roleId];
if (existing == null) {
summary[roleId] = _RoleSummary(
@@ -123,14 +158,16 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
}
return summary.values
.map((_RoleSummary item) => InvoiceItem(
id: item.roleId,
invoiceId: item.roleId,
staffId: item.roleName,
workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue,
))
.map(
(_RoleSummary item) => InvoiceItem(
id: item.roleId,
invoiceId: item.roleId,
staffId: item.roleName,
workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue,
),
)
.toList();
});
}
@@ -146,7 +183,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
}
@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 {
await _service.connector
.updateInvoice(id: id)
@@ -159,36 +199,100 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
// --- MAPPERS ---
Invoice _mapInvoice(dynamic invoice) {
final List<dynamic> rolesData = invoice.roles is List ? invoice.roles : [];
final List<InvoiceWorker> workers = rolesData.map((dynamic r) {
final Map<String, dynamic> role = r as Map<String, dynamic>;
// Handle various possible key naming conventions in the JSON data
final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
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['totalHours'] 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 checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
List<InvoiceWorker> workers = <InvoiceWorker>[];
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(checkInVal),
checkOut: _service.toDateTime(checkOutVal),
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
);
}).toList();
// 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>;
// Handle various possible key naming conventions in the JSON data
final String name =
role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final String roleTitle =
role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
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['totalHours'] 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 checkOutVal =
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(checkInVal),
checkOut: _service.toDateTime(checkOutVal),
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
avatarUrl:
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
);
}).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(
id: invoice.id,
@@ -202,8 +306,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
issueDate: _service.toDateTime(invoice.issueDate)!,
title: invoice.order?.eventName,
clientName: invoice.business?.businessName,
locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
locationAddress:
invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
staffCount:
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
totalHours: _calculateTotalHours(rolesData),
workers: workers,
);
@@ -256,10 +362,7 @@ class _RoleSummary {
final double totalHours;
final double totalValue;
_RoleSummary copyWith({
double? totalHours,
double? totalValue,
}) {
_RoleSummary copyWith({double? totalHours, double? totalValue}) {
return _RoleSummary(
roleId: roleId,
roleName: roleName,
@@ -268,4 +371,3 @@ class _RoleSummary {
);
}
}

View File

@@ -205,13 +205,23 @@ mixin SessionHandlerMixin {
try {
_emitSessionState(SessionState.loading());
// Validate role if allowed roles are specified
// Validate role only when allowed roles are specified.
if (_allowedRoles.isNotEmpty) {
final bool isAuthorized = await validateUserRole(
user.uid,
_allowedRoles,
);
if (!isAuthorized) {
final String? userRole = await fetchUserRole(user.uid);
if (userRole == null) {
// User has no record in the database yet. This is expected during
// 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();
_emitSessionState(SessionState.unauthenticated());
return;

View File

@@ -12,3 +12,5 @@ export 'src/widgets/ui_button.dart';
export 'src/widgets/ui_chip.dart';
export 'src/widgets/ui_loading_page.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
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,
);
/// 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)
static final TextStyle title2b = _primaryBase.copyWith(
fontWeight: FontWeight.w600,
@@ -264,6 +273,16 @@ class UiTypography {
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)
static final TextStyle titleUppercase3m = _primaryBase.copyWith(
fontWeight: FontWeight.w500,

View File

@@ -3,7 +3,6 @@ import '../ui_constants.dart';
/// A custom button widget with different variants and icon support.
class UiButton extends StatelessWidget {
/// Creates a [UiButton] with a custom button builder.
const UiButton({
super.key,
@@ -17,6 +16,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20,
this.size = UiButtonSize.large,
this.fullWidth = false,
this.isLoading = false,
}) : assert(
text != null || child != null,
'Either text or child must be provided',
@@ -34,6 +34,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20,
this.size = UiButtonSize.large,
this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _elevatedButtonBuilder,
assert(
text != null || child != null,
@@ -50,8 +51,9 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.large,
this.size = UiButtonSize.large,
this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _outlinedButtonBuilder,
assert(
text != null || child != null,
@@ -70,6 +72,7 @@ class UiButton extends StatelessWidget {
this.iconSize = 20,
this.size = UiButtonSize.large,
this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _textButtonBuilder,
assert(
text != null || child != null,
@@ -88,11 +91,13 @@ class UiButton extends StatelessWidget {
this.iconSize = 20,
this.size = UiButtonSize.large,
this.fullWidth = false,
this.isLoading = false,
}) : buttonBuilder = _textButtonBuilder,
assert(
text != null || child != null,
'Either text or child must be provided',
);
/// The text to display on the button.
final String? text;
@@ -129,18 +134,21 @@ class UiButton extends StatelessWidget {
)
buttonBuilder;
/// Whether to show a loading indicator.
final bool isLoading;
@override
/// Builds the button UI.
Widget build(BuildContext context) {
final ButtonStyle mergedStyle = style != null
? _getSizeStyle().merge(style)
final ButtonStyle mergedStyle = style != null
? _getSizeStyle().merge(style)
: _getSizeStyle();
final Widget button = buttonBuilder(
context,
onPressed,
isLoading ? null : onPressed,
mergedStyle,
_buildButtonContent(),
isLoading ? _buildLoadingContent() : _buildButtonContent(),
);
if (fullWidth) {
@@ -150,6 +158,15 @@ class UiButton extends StatelessWidget {
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.
ButtonStyle _getSizeStyle() {
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
return await _createBusinessAndUser(
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
final bool hasBusinessAccount = await _checkBusinessUserExists(
firebaseUser.uid,
@@ -329,7 +339,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Future<void> signOut() async {
try {
await _service.auth.signOut();
dc.ClientSessionStore.instance.clear();
_service.clearCache();
} catch (e) {
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/dispute_invoice.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/pages/billing_page.dart';
import 'presentation/pages/completion_review_page.dart';
@@ -44,6 +45,10 @@ class BillingModule extends Module {
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
),
);
i.add<ShiftCompletionReviewBloc>(
() => ShiftCompletionReviewBloc(
approveInvoice: i.get<ApproveInvoiceUseCase>(),
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_savings_amount.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/spending_breakdown_model.dart';
import 'billing_event.dart';
@@ -26,21 +24,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
_approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged);
on<BillingInvoiceApproved>(_onInvoiceApproved);
on<BillingInvoiceDisputed>(_onInvoiceDisputed);
}
final GetBankAccountsUseCase _getBankAccounts;
@@ -49,8 +41,6 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final ApproveInvoiceUseCase _approveInvoice;
final DisputeInvoiceUseCase _disputeInvoice;
Future<void> _onLoadStarted(
BillingLoadStarted event,
@@ -62,13 +52,13 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
action: () async {
final List<dynamic> results =
await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
@@ -78,10 +68,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices =
pendingInvoices.map(_mapInvoiceToUiModel).toList();
final List<BillingInvoice> uiInvoiceHistory =
invoiceHistory.map(_mapInvoiceToUiModel).toList();
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
@@ -101,10 +93,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
),
);
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -115,8 +105,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError(
emit: emit.call,
action: () async {
final List<InvoiceItem> spendingItems =
await _getSpendingBreakdown.call(event.period);
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
.call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
@@ -131,46 +121,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
),
);
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
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,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -180,15 +132,18 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
? 'N/A'
: 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(
workerName: w.name,
roleName: w.role,
totalAmount: w.amount,
hours: w.hours,
rate: w.rate,
startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--',
endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--',
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
@@ -196,33 +151,35 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
String? overallStart;
String? overallEnd;
// Find valid times from workers instead of just taking the first one
final validStartTimes = workers
.where((w) => w.startTime != '--:--')
.map((w) => w.startTime)
// Find valid times from actual DateTime checks to ensure chronological sorting
final List<DateTime> validCheckIns = invoice.workers
.where((InvoiceWorker w) => w.checkIn != null)
.map((InvoiceWorker w) => w.checkIn!)
.toList();
final validEndTimes = workers
.where((w) => w.endTime != '--:--')
.map((w) => w.endTime)
final List<DateTime> validCheckOuts = invoice.workers
.where((InvoiceWorker w) => w.checkOut != null)
.map((InvoiceWorker w) => w.checkOut!)
.toList();
if (validStartTimes.isNotEmpty) {
validStartTimes.sort();
overallStart = validStartTimes.first;
final DateFormat timeFormat = DateFormat('h:mm a');
if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validEndTimes.isNotEmpty) {
validEndTimes.sort();
overallEnd = validEndTimes.last;
if (validCheckOuts.isNotEmpty) {
validCheckOuts.sort();
overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: invoice.invoiceNumber ?? invoice.id,
id: invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',

View File

@@ -24,20 +24,3 @@ class BillingPeriodChanged extends BillingEvent {
@override
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.totalHours,
required this.status,
this.workers = const [],
this.workers = const <BillingWorkerRecord>[],
this.startTime,
this.endTime,
});
@@ -70,7 +70,7 @@ class BillingWorkerRecord extends Equatable {
final String? workerAvatarUrl;
@override
List<Object?> get props => [
List<Object?> get props => <Object?>[
workerName,
roleName,
totalAmount,

View File

@@ -9,7 +9,6 @@ import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/payment_method_card.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart';
@@ -97,7 +96,7 @@ class _BillingViewState extends State<BillingView> {
leading: Center(
child: UiIconButton(
icon: UiIcons.arrowLeft,
backgroundColor: UiColors.white.withOpacity(0.15),
backgroundColor: UiColors.white.withValues(alpha: 0.15),
iconColor: UiColors.white,
useBlur: true,
size: 40,
@@ -106,21 +105,21 @@ class _BillingViewState extends State<BillingView> {
),
title: Text(
t.client_billing.title,
style: UiTypography.headline3b.copyWith(color: UiColors.white),
style: UiTypography.headline3b.copyWith(
color: UiColors.white,
),
),
centerTitle: false,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space8,
),
padding: const EdgeInsets.only(bottom: UiConstants.space8),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Text(
t.client_billing.current_period,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withOpacity(0.7),
color: UiColors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: UiConstants.space1),
@@ -224,156 +223,13 @@ class _BillingViewState extends State<BillingView> {
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
],
const PaymentMethodCard(),
// const PaymentMethodCard(),
const SpendingBreakdownCard(),
_buildSavingsCard(state.savings),
if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory),
_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,
),
const SizedBox(height: UiConstants.space16),
],
),
);
}
}
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,20 +1,23 @@
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/billing_bloc.dart';
import '../blocs/billing_event.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 {
const ShiftCompletionReviewPage({this.invoice, super.key});
final BillingInvoice? invoice;
@override
State<ShiftCompletionReviewPage> createState() => _ShiftCompletionReviewPageState();
State<ShiftCompletionReviewPage> createState() =>
_ShiftCompletionReviewPageState();
}
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
@@ -26,395 +29,65 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
void initState() {
super.initState();
// 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
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;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList();
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: UiAppBar(
title: invoice.title,
subtitle: invoice.clientName,
showBackButton: true,
),
body: SafeArea(
child: Column(
children: <Widget>[
_buildHeader(context),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space4),
_buildInvoiceInfoCard(),
const SizedBox(height: UiConstants.space4),
_buildAmountCard(),
const SizedBox(height: UiConstants.space6),
_buildWorkersHeader(),
const SizedBox(height: UiConstants.space4),
_buildSearchAndTabs(),
const SizedBox(height: UiConstants.space4),
...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)),
const SizedBox(height: UiConstants.space6),
_buildActionButtons(context),
const SizedBox(height: UiConstants.space4),
_buildDownloadLink(),
const SizedBox(height: UiConstants.space8),
],
),
),
),
],
),
),
);
}
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(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
),
child: Column(
children: <Widget>[
Row(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
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),
CompletionReviewInfo(invoice: invoice),
const SizedBox(height: UiConstants.space4),
CompletionReviewAmount(invoice: invoice),
const SizedBox(height: UiConstants.space6),
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
// const SizedBox(height: UiConstants.space4),
// CompletionReviewSearchAndTabs(
// selectedTab: selectedTab,
// workersCount: invoice.workersCount,
// onTabChanged: (int index) =>
// setState(() => selectedTab = index),
// onSearchChanged: (String val) =>
// setState(() => searchQuery = val),
// ),
// const SizedBox(height: UiConstants.space4),
// ...filteredWorkers.map(
// (BillingWorkerRecord worker) =>
// CompletionReviewWorkerCard(worker: worker),
// ),
// const SizedBox(height: UiConstants.space4),
],
),
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,
bottomNavigationBar: Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
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),
),
],
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
),
);
}

View File

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

View File

@@ -3,6 +3,7 @@ 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/billing_bloc.dart';
import '../blocs/billing_state.dart';
@@ -20,6 +21,7 @@ class PendingInvoicesPage extends StatelessWidget {
appBar: UiAppBar(
title: t.client_billing.awaiting_approval,
showBackButton: true,
onLeadingPressed: () => Modular.to.toClientBilling(),
),
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
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.invoice_history,
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),
],
),
),
],
Text(
t.client_billing.invoice_history,
style: UiTypography.title2b.textPrimary,
),
const SizedBox(height: UiConstants.space3),
Container(
decoration: BoxDecoration(
color: UiColors.white,
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(
color: UiColors.black.withValues(alpha: 0.04),
@@ -99,7 +77,7 @@ class _InvoiceItem extends StatelessWidget {
),
child: Icon(
UiIcons.file,
color: UiColors.iconSecondary.withOpacity(0.6),
color: UiColors.iconSecondary.withValues(alpha: 0.6),
size: 20,
),
),
@@ -108,10 +86,7 @@ class _InvoiceItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
invoice.id,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
Text(invoice.title, style: UiTypography.body1r.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
@@ -129,12 +104,6 @@ class _InvoiceItem extends StatelessWidget {
_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(
onTap: () => Modular.to.toAwaitingApproval(),
child: Container(
padding: const EdgeInsets.all(UiConstants.space5),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Row(
children: <Widget>[
@@ -48,9 +41,9 @@ class PendingInvoicesSection extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Row(
children: [
children: <Widget>[
Text(
t.client_billing.awaiting_approval,
style: UiTypography.body1b.textPrimary,
@@ -86,7 +79,7 @@ class PendingInvoicesSection extends StatelessWidget {
Icon(
UiIcons.chevronRight,
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
const Icon(
@@ -134,8 +129,6 @@ class PendingInvoiceCard extends StatelessWidget {
],
),
const SizedBox(height: UiConstants.space2),
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
Text(
@@ -187,7 +180,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container(
width: 1,
height: 32,
color: UiColors.border.withOpacity(0.3),
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
@@ -199,7 +192,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container(
width: 1,
height: 32,
color: UiColors.border.withOpacity(0.3),
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
@@ -232,7 +225,11 @@ class PendingInvoiceCard extends StatelessWidget {
Widget _buildStatItem(IconData icon, String value, String label) {
return Column(
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),
Text(
value,

View File

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

View File

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

View File

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

View File

@@ -99,16 +99,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override
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 {
final String businessId = await _service.getBusinessId();
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>

View File

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

View File

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

View File

@@ -17,11 +17,14 @@ class ClientCreateOrderRepositoryImpl
ClientCreateOrderRepositoryImpl({
required dc.DataConnectService service,
required RapidOrderService rapidOrderService,
required FileUploadService fileUploadService,
}) : _service = service,
_rapidOrderService = rapidOrderService;
_rapidOrderService = rapidOrderService,
_fileUploadService = fileUploadService;
final dc.DataConnectService _service;
final RapidOrderService _rapidOrderService;
final FileUploadService _fileUploadService;
@override
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
@@ -379,29 +382,82 @@ class ClientCreateOrderRepositoryImpl
);
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 =
DateTime.tryParse(data.startAt ?? '') ?? DateTime.now();
final DateTime endAt =
DateTime.tryParse(data.endAt ?? '') ??
startAt.add(const Duration(hours: 8));
final String startTimeStr = DateFormat('hh:mm a').format(startAt);
final String endTimeStr = DateFormat('hh:mm a').format(endAt);
final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal());
final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal());
return domain.OneTimeOrder(
date: startAt,
location: data.locationHint ?? '',
location: bestHub?.hubName ?? data.locationHint ?? '',
eventName: data.notes ?? '',
hub: data.locationHint != null
vendorId: selectedVendorId,
hub: bestHub != null
? domain.OneTimeOrderHubDetails(
id: '',
name: data.locationHint!,
address: '',
id: bestHub.id,
name: bestHub.hubName,
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,
positions: data.positions.map((RapidOrderPosition p) {
final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole(
availableRoles,
p.role,
);
return domain.OneTimeOrderPosition(
role: p.role,
role: matchedRole?.id ?? p.role,
count: p.count,
startTime: startTimeStr,
endTime: endTimeStr,
@@ -412,8 +468,18 @@ class ClientCreateOrderRepositoryImpl
@override
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
.transcribeAudio(audioFileUri: audioPath);
.transcribeAudio(audioFileUri: uploadResponse.fileUri);
return response.transcript;
}
@@ -643,4 +709,85 @@ class ClientCreateOrderRepositoryImpl
}
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._audioRecorderService,
) : super(
const RapidOrderInitial(
const RapidOrderState(
examples: <String>[
'"We had a call out. Need 2 cooks ASAP"',
'"Need 5 bartenders ASAP until 5am"',
@@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderMessageChanged event,
Emitter<RapidOrderState> emit,
) {
if (state is RapidOrderInitial) {
emit((state as RapidOrderInitial).copyWith(message: event.message));
}
emit(
state.copyWith(message: event.message, status: RapidOrderStatus.initial),
);
}
Future<void> _onVoiceToggled(
RapidOrderVoiceToggled event,
Emitter<RapidOrderState> emit,
) async {
if (state is RapidOrderInitial) {
final RapidOrderInitial currentState = state as RapidOrderInitial;
final bool newListeningState = !currentState.isListening;
emit(currentState.copyWith(isListening: newListeningState));
// Simulate voice recognition
if (newListeningState) {
await Future<void>.delayed(const Duration(seconds: 2));
if (state is RapidOrderInitial) {
if (!state.isListening) {
// Start Recording
await handleError(
emit: emit.call,
action: () async {
await _audioRecorderService.startRecording();
emit(
(state as RapidOrderInitial).copyWith(
message: 'Need 2 servers for a banquet right now.',
state.copyWith(isListening: true, status: RapidOrderStatus.initial),
);
},
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,
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,
Emitter<RapidOrderState> emit,
) async {
final RapidOrderState currentState = state;
if (currentState is RapidOrderInitial) {
final String message = currentState.message;
emit(const RapidOrderSubmitting());
final String message = state.message;
emit(state.copyWith(status: RapidOrderStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(RapidOrderParsed(order));
},
onError: (String errorKey) => RapidOrderFailure(errorKey),
);
}
await handleError(
emit: emit.call,
action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(
state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
);
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
);
}
void _onExampleSelected(
RapidOrderExampleSelected event,
Emitter<RapidOrderState> emit,
) {
if (state is RapidOrderInitial) {
final String cleanedExample = event.example.replaceAll('"', '');
emit((state as RapidOrderInitial).copyWith(message: cleanedExample));
}
final String cleanedExample = event.example.replaceAll('"', '');
emit(
state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
);
}
}

View File

@@ -1,59 +1,55 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class RapidOrderState extends Equatable {
const RapidOrderState();
enum RapidOrderStatus { initial, submitting, parsed, failure }
@override
List<Object?> get props => <Object?>[];
}
class RapidOrderInitial extends RapidOrderState {
const RapidOrderInitial({
class RapidOrderState extends Equatable {
const RapidOrderState({
this.status = RapidOrderStatus.initial,
this.message = '',
this.isListening = false,
required this.examples,
this.isTranscribing = false,
this.examples = const <String>[],
this.error,
this.parsedOrder,
});
final RapidOrderStatus status;
final String message;
final bool isListening;
final bool isTranscribing;
final List<String> examples;
final String? error;
final OneTimeOrder? parsedOrder;
@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,
bool? isListening,
bool? isTranscribing,
List<String>? examples,
String? error,
OneTimeOrder? parsedOrder,
}) {
return RapidOrderInitial(
return RapidOrderState(
status: status ?? this.status,
message: message ?? this.message,
isListening: isListening ?? this.isListening,
isTranscribing: isTranscribing ?? this.isTranscribing,
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,
hubManagers: state.managers.map(_mapManager).toList(),
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) =>
bloc.add(OneTimeOrderEventNameChanged(val)),
onVendorChanged: (Vendor val) =>

View File

@@ -12,18 +12,16 @@ class UiOrderType {
/// Order type constants for the create order feature
const List<UiOrderType> orderTypes = <UiOrderType>[
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
// UiOrderType(
// id: 'rapid',
// titleKey: 'client_create_order.types.rapid',
// descriptionKey: 'client_create_order.types.rapid_desc',
// ),
UiOrderType(
id: 'rapid',
titleKey: 'client_create_order.types.rapid',
descriptionKey: 'client_create_order.types.rapid_desc',
),
UiOrderType(
id: 'one-time',
titleKey: 'client_create_order.types.one_time',
descriptionKey: 'client_create_order.types.one_time_desc',
),
UiOrderType(
id: '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 'rapid_order_example_card.dart';
import 'rapid_order_header.dart';
import 'rapid_order_success_view.dart';
/// The main content of the Rapid Order page.
class RapidOrderView extends StatelessWidget {
@@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget {
@override
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>(
listener: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderInitial) {
if (state.status == RapidOrderStatus.initial) {
if (_messageController.text != state.message) {
_messageController.text = state.message;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: _messageController.text.length),
);
}
} else if (state is RapidOrderParsed) {
} else if (state.status == RapidOrderStatus.parsed &&
state.parsedOrder != null) {
Modular.to.toCreateOrderOneTime(
arguments: <String, dynamic>{
'order': state.order,
'order': state.parsedOrder,
'isRapidDraft': true,
},
);
} else if (state is RapidOrderFailure) {
} else if (state.status == RapidOrderStatus.failure &&
state.error != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.error),
message: translateErrorKey(state.error!),
type: UiSnackbarType.error,
);
}
@@ -95,68 +80,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
subtitle: labels.subtitle,
date: dateStr,
time: timeStr,
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
onBack: () => Modular.to.toCreateOrder(),
),
// Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
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),
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final bool isSubmitting =
state.status == RapidOrderStatus.submitting;
// 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(
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Icon
const _AnimatedZapIcon(),
const SizedBox(height: UiConstants.space4),
Text(
labels.need_staff,
style: UiTypography.headline2m.textPrimary,
style: UiTypography.headline3b.textPrimary,
),
const SizedBox(height: UiConstants.space2),
Text(
labels.type_or_speak,
textAlign: TextAlign.center,
@@ -165,31 +112,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
const SizedBox(height: UiConstants.space6),
// Examples
if (initialState != null)
...initialState.examples.asMap().entries.map((
MapEntry<int, String> entry,
) {
final int index = entry.key;
final String example = entry.value;
final bool isHighlighted = index == 0;
...state.examples.asMap().entries.map((
MapEntry<int, String> entry,
) {
final int index = entry.key;
final String example = entry.value;
final bool isHighlighted = index == 0;
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space2,
),
child: RapidOrderExampleCard(
example: example,
isHighlighted: isHighlighted,
label: labels.example,
onTap: () =>
BlocProvider.of<RapidOrderBloc>(
context,
).add(
RapidOrderExampleSelected(example),
),
),
);
}),
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space2,
),
child: RapidOrderExampleCard(
example: example,
isHighlighted: isHighlighted,
label: labels.example,
onTap: () =>
BlocProvider.of<RapidOrderBloc>(
context,
).add(
RapidOrderExampleSelected(example),
),
),
);
}),
const SizedBox(height: UiConstants.space4),
// Input
@@ -203,24 +149,23 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
},
hintText: labels.hint,
),
const SizedBox(height: UiConstants.space4),
// Actions
_RapidOrderActions(
labels: labels,
isSubmitting: isSubmitting,
isListening: initialState?.isListening ?? false,
isMessageEmpty:
initialState != null &&
initialState.message.trim().isEmpty,
),
],
);
},
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: _RapidOrderActions(
labels: labels,
isSubmitting: isSubmitting,
isListening: state.isListening,
isTranscribing: state.isTranscribing,
isMessageEmpty: state.message.trim().isEmpty,
),
),
],
);
},
),
),
],
@@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget {
end: Alignment.bottomRight,
),
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),
);
@@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget {
required this.labels,
required this.isSubmitting,
required this.isListening,
required this.isTranscribing,
required this.isMessageEmpty,
});
final TranslationsClientCreateOrderRapidEn labels;
final bool isSubmitting;
final bool isListening;
final bool isTranscribing;
final bool isMessageEmpty;
@override
@@ -279,11 +219,17 @@ class _RapidOrderActions extends StatelessWidget {
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: isListening ? labels.listening : labels.speak,
text: isTranscribing
? labels.transcribing
: isListening
? labels.listening
: labels.speak,
leadingIcon: UiIcons.microphone,
onPressed: () => BlocProvider.of<RapidOrderBloc>(
context,
).add(const RapidOrderVoiceToggled()),
onPressed: isTranscribing
? null
: () => BlocProvider.of<RapidOrderBloc>(
context,
).add(const RapidOrderVoiceToggled()),
style: OutlinedButton.styleFrom(
backgroundColor: isListening
? UiColors.destructive.withValues(alpha: 0.05)

View File

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

View File

@@ -2,13 +2,13 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// A quick report card widget for navigating to specific reports.
///
/// Displays an icon, name, and a quick navigation to a report page.
/// Used in the quick reports grid of the reports page.
class ReportCard extends StatelessWidget {
const ReportCard({
super.key,
required this.icon,
@@ -17,6 +17,7 @@ class ReportCard extends StatelessWidget {
required this.iconColor,
required this.route,
});
/// The icon to display for this report.
final IconData icon;
@@ -35,7 +36,7 @@ class ReportCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Modular.to.pushNamed(route),
onTap: () => Modular.to.safePush(route),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -86,8 +87,7 @@ class ReportCard extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
context.t.client_reports.quick_reports
.two_click_export,
context.t.client_reports.quick_reports.two_click_export,
style: const TextStyle(
fontSize: 12,
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
/// [PhoneInput] and [OtpVerification] based on the current [AuthState].
class PhoneVerificationPage extends StatefulWidget {
/// Creates a [PhoneVerificationPage].
const PhoneVerificationPage({super.key, required this.mode});
/// The authentication mode (login or signup).
final AuthMode mode;
@@ -123,10 +123,10 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
);
Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return;
Modular.to.navigate('/');
Modular.to.toInitialPage();
});
} 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
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(
value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>(
@@ -60,22 +62,17 @@ class _ClockInPageState extends State<ClockInPage> {
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime =
isActiveSelected ? state.attendance.checkInTime : null;
final DateTime? checkOutTime =
isActiveSelected ? state.attendance.checkOutTime : null;
final DateTime? checkInTime = isActiveSelected
? state.attendance.checkInTime
: null;
final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Scaffold(
appBar: UiAppBar(
titleWidget: Text(
i18n.title,
style: UiTypography.title1m.textPrimary,
),
showBackButton: false,
centerTitle: false,
),
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
@@ -92,18 +89,18 @@ class _ClockInPageState extends State<ClockInPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Commute Tracker (shows before date selector when applicable)
if (selectedShift != null)
CommuteTracker(
shift: selectedShift,
hasLocationConsent: state.hasLocationConsent,
isCommuteModeOn: state.isCommuteModeOn,
distanceMeters: state.distanceFromVenue,
etaMinutes: state.etaMinutes,
onCommuteToggled: (bool value) {
_bloc.add(CommuteModeToggled(value));
},
),
// // Commute Tracker (shows before date selector when applicable)
// if (selectedShift != null)
// CommuteTracker(
// shift: selectedShift,
// hasLocationConsent: state.hasLocationConsent,
// isCommuteModeOn: state.isCommuteModeOn,
// distanceMeters: state.distanceFromVenue,
// etaMinutes: state.etaMinutes,
// onCommuteToggled: (bool value) {
// _bloc.add(CommuteModeToggled(value));
// },
// ),
// Date Selector
DateSelector(
selectedDate: state.selectedDate,
@@ -141,17 +138,14 @@ class _ClockInPageState extends State<ClockInPage> {
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius:
UiConstants.radiusLg,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: shift.id ==
selectedShift?.id
color: shift.id == selectedShift?.id
? UiColors.primary
: UiColors.border,
width:
shift.id == selectedShift?.id
? 2
: 1,
width: shift.id == selectedShift?.id
? 2
: 1,
),
),
child: Row(
@@ -163,23 +157,23 @@ class _ClockInPageState extends State<ClockInPage> {
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
shift.id ==
selectedShift?.id
? i18n
.selected_shift_badge
: i18n
.today_shift_badge,
style: UiTypography
.titleUppercase4b
.copyWith(
color: shift.id ==
selectedShift?.id
? UiColors.primary
: UiColors
.textSecondary,
),
),
Text(
shift.id ==
selectedShift?.id
? i18n.selected_shift_badge
: i18n.today_shift_badge,
style: UiTypography
.titleUppercase4b
.copyWith(
color:
shift.id ==
selectedShift
?.id
? UiColors.primary
: UiColors
.textSecondary,
),
),
const SizedBox(height: 2),
Text(
shift.title,
@@ -187,7 +181,8 @@ class _ClockInPageState extends State<ClockInPage> {
),
Text(
"${shift.clientName} • ${shift.location}",
style: UiTypography.body3r
style: UiTypography
.body3r
.textSecondary,
),
],
@@ -199,15 +194,16 @@ class _ClockInPageState extends State<ClockInPage> {
children: <Widget>[
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.body3m
style: UiTypography
.body3m
.textSecondary,
),
Text(
"\$${shift.hourlyRate}/hr",
style: UiTypography.body3m
.copyWith(
color: UiColors.primary,
),
color: UiColors.primary,
),
),
],
),
@@ -226,8 +222,9 @@ class _ClockInPageState extends State<ClockInPage> {
!_isCheckInAllowed(selectedShift))
Container(
width: double.infinity,
padding:
const EdgeInsets.all(UiConstants.space6),
padding: const EdgeInsets.all(
UiConstants.space6,
),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
@@ -259,81 +256,109 @@ class _ClockInPageState extends State<ClockInPage> {
)
else ...<Widget>[
// Attire Photo Section
if (!isCheckedIn) ...<Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space4),
margin: const EdgeInsets.only(bottom: UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(UiIcons.camera, color: UiColors.primary),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(i18n.attire_photo_label, style: UiTypography.body2b),
Text(i18n.attire_photo_desc, style: UiTypography.body3r.textSecondary),
],
),
),
UiButton.secondary(
text: i18n.take_attire_photo,
onPressed: () {
UiSnackbar.show(
context,
message: i18n.attire_captured,
type: UiSnackbarType.success,
);
},
),
],
),
),
],
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,
),
),
],
),
),
],
// if (!isCheckedIn) ...<Widget>[
// Container(
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.white,
// borderRadius: UiConstants.radiusLg,
// border: Border.all(color: UiColors.border),
// ),
// child: Row(
// children: <Widget>[
// Container(
// width: 48,
// height: 48,
// decoration: BoxDecoration(
// color: UiColors.bgSecondary,
// borderRadius: UiConstants.radiusMd,
// ),
// child: const Icon(
// UiIcons.camera,
// color: UiColors.primary,
// ),
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: <Widget>[
// Text(
// i18n.attire_photo_label,
// style: UiTypography.body2b,
// ),
// Text(
// i18n.attire_photo_desc,
// style: UiTypography
// .body3r
// .textSecondary,
// ),
// ],
// ),
// ),
// UiButton.secondary(
// text: i18n.take_attire_photo,
// onPressed: () {
// UiSnackbar.show(
// context,
// message: i18n.attire_captured,
// type: UiSnackbarType.success,
// );
// },
// ),
// ],
// ),
// ),
// ],
// 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(
isCheckedIn: isCheckedIn,
mode: state.checkInMode,
isDisabled: !isCheckedIn && !state.isLocationVerified,
isDisabled: isCheckedIn,
isLoading:
state.status ==
ClockInStatus.actionInProgress,
@@ -554,7 +579,9 @@ class _ClockInPageState extends State<ClockInPage> {
}
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;
// Using a local navigator context since we are in a dialog
@@ -668,8 +695,14 @@ class _ClockInPageState extends State<ClockInPage> {
try {
final List<String> parts = timeStr.split(':');
if (parts.length >= 2) {
final DateTime dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1]));
return DateFormat('h:mm a').format(dt);
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
}
return timeStr;
} catch (e) {
@@ -683,7 +716,9 @@ class _ClockInPageState extends State<ClockInPage> {
// Parse shift date (e.g. 2024-01-31T09:00:00)
// The Shift entity has 'date' which is the start DateTime string
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);
} catch (e) {
// Fallback: If parsing fails, allow check in to avoid blocking.
@@ -692,15 +727,17 @@ class _ClockInPageState extends State<ClockInPage> {
}
String _getCheckInAvailabilityTime(Shift shift) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15));
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in;
return i18n.soon;
}
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon;
}
}
}

View File

@@ -1,11 +1,10 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.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:staff_home/src/domain/usecases/get_home_shifts.dart';
import 'package:krow_domain/krow_domain.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';
@@ -14,18 +13,18 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
final GetHomeShifts _getHomeShifts;
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)
/// should be enabled on the home screen.
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletion;
final GetProfileCompletionUseCase _getProfileCompletion;
HomeCubit({
required HomeRepository repository,
required GetPersonalInfoCompletionUseCase getPersonalInfoCompletion,
required GetProfileCompletionUseCase getProfileCompletion,
}) : _getHomeShifts = GetHomeShifts(repository),
_repository = repository,
_getPersonalInfoCompletion = getPersonalInfoCompletion,
_getProfileCompletion = getProfileCompletion,
super(const HomeState.initial());
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
final results = await Future.wait([
_getHomeShifts.call(),
_getPersonalInfoCompletion.call(),
_getProfileCompletion.call(),
_repository.getBenefits(),
_repository.getStaffName(),
]);

View File

@@ -61,169 +61,186 @@ class WorkerHomePage extends StatelessWidget {
horizontal: UiConstants.space4,
vertical: UiConstants.space4,
),
child: Column(
children: [
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
if (state.isProfileComplete) return const SizedBox();
return PlaceholderBanner(
title: bannersI18n.complete_profile_title,
subtitle: bannersI18n.complete_profile_subtitle,
bg: UiColors.primaryInverse,
accent: UiColors.primary,
onTap: () {
Modular.to.toProfile();
},
);
},
),
const SizedBox(height: UiConstants.space6),
// Quick Actions
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: QuickActionItem(
icon: UiIcons.search,
label: quickI18n.find_shifts,
onTap: () => Modular.to.toShifts(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.calendar,
label: quickI18n.availability,
onTap: () => Modular.to.toAvailability(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.dollar,
label: quickI18n.earnings,
onTap: () => Modular.to.toPayments(),
),
),
],
),
const SizedBox(height: UiConstants.space6),
// Today's Shifts
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.todayShifts;
return Column(
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(
children: [
SectionHeader(
title: sectionsI18n.todays_shift,
action: shifts.isNotEmpty
? sectionsI18n.scheduled_count(
count: shifts.length,
)
: null,
PlaceholderBanner(
title: bannersI18n.complete_profile_title,
subtitle: bannersI18n.complete_profile_subtitle,
bg: UiColors.primaryInverse,
accent: UiColors.primary,
onTap: () {
Modular.to.toProfile();
},
),
if (state.status == HomeStatus.loading)
const Center(
child: SizedBox(
height: UiConstants.space10,
width: UiConstants.space10,
child: CircularProgressIndicator(
color: UiColors.primary,
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.',
),
),
],
),
);
}
return Column(
children: [
// Quick Actions
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: QuickActionItem(
icon: UiIcons.search,
label: quickI18n.find_shifts,
onTap: () => Modular.to.toShifts(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.calendar,
label: quickI18n.availability,
onTap: () => Modular.to.toAvailability(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.dollar,
label: quickI18n.earnings,
onTap: () => Modular.to.toPayments(),
),
),
],
),
const SizedBox(height: UiConstants.space6),
// Today's Shifts
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.todayShifts;
return Column(
children: [
SectionHeader(
title: sectionsI18n.todays_shift,
action: shifts.isNotEmpty
? sectionsI18n.scheduled_count(
count: shifts.length,
)
: null,
),
if (state.status == HomeStatus.loading)
const Center(
child: SizedBox(
height: UiConstants.space10,
width: UiConstants.space10,
child: CircularProgressIndicator(
color: UiColors.primary,
),
),
)
else if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_today,
actionLink: emptyI18n.find_shifts_cta,
onAction: () =>
Modular.to.toShifts(initialTab: 'find'),
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
// Tomorrow's Shifts
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.tomorrowShifts;
return Column(
children: [
SectionHeader(title: sectionsI18n.tomorrow),
if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_tomorrow,
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
// Recommended Shifts
SectionHeader(title: sectionsI18n.recommended_for_you),
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
if (state.recommendedShifts.isEmpty) {
return EmptyStateWidget(
message: emptyI18n.no_recommended_shifts,
);
}
return SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.recommendedShifts.length,
clipBehavior: Clip.none,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(
right: UiConstants.space3,
),
child: RecommendedShiftCard(
shift: state.recommendedShifts[index],
),
),
)
else if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_today,
actionLink: emptyI18n.find_shifts_cta,
onAction: () =>
Modular.to.toShifts(initialTab: 'find'),
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
);
},
),
const SizedBox(height: UiConstants.space6),
// Tomorrow's Shifts
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.tomorrowShifts;
return Column(
children: [
SectionHeader(title: sectionsI18n.tomorrow),
if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_tomorrow,
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
// Recommended Shifts
SectionHeader(title: sectionsI18n.recommended_for_you),
BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
if (state.recommendedShifts.isEmpty) {
return EmptyStateWidget(
message: emptyI18n.no_recommended_shifts,
);
}
return SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.recommendedShifts.length,
clipBehavior: Clip.none,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(
right: UiConstants.space3,
),
child: RecommendedShiftCard(
shift: state.recommendedShifts[index],
),
),
),
);
},
),
const SizedBox(height: UiConstants.space6),
// Benefits
BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) =>
previous.benefits != current.benefits,
builder: (context, state) {
return BenefitsWidget(benefits: state.benefits);
},
),
const SizedBox(height: UiConstants.space6),
],
// Benefits
BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) =>
previous.benefits != current.benefits,
builder: (context, state) {
return BenefitsWidget(benefits: state.benefits);
},
),
const SizedBox(height: UiConstants.space6),
],
);
},
),
),
],

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
import 'dart:io';
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 '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_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).
class CertificateUploadPage extends StatefulWidget {
@@ -25,7 +25,6 @@ class CertificateUploadPage extends StatefulWidget {
}
class _CertificateUploadPageState extends State<CertificateUploadPage> {
String? _selectedFilePath;
DateTime? _selectedExpiryDate;
final TextEditingController _issuerController = TextEditingController();
final TextEditingController _numberController = TextEditingController();
@@ -35,16 +34,21 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
final FilePickerService _filePicker = Modular.get<FilePickerService>();
bool get _isNewCertificate => widget.certificate == null;
late CertificateUploadCubit _cubit;
@override
void initState() {
super.initState();
_cubit = Modular.get<CertificateUploadCubit>();
if (widget.certificate != null) {
_selectedExpiryDate = widget.certificate!.expiryDate;
_issuerController.text = widget.certificate!.issuer ?? '';
_numberController.text = widget.certificate!.certificateNumber ?? '';
_nameController.text = widget.certificate!.name;
_selectedType = widget.certificate!.certificationType;
_selectedFilePath = widget.certificate?.certificateUrl;
} else {
_selectedType = ComplianceType.other;
}
@@ -80,9 +84,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
);
return;
}
setState(() {
_selectedFilePath = path;
});
_cubit.setSelectedFilePath(path);
}
}
@@ -145,8 +147,10 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
@override
Widget build(BuildContext context) {
return BlocProvider<CertificateUploadCubit>(
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(),
return BlocProvider<CertificateUploadCubit>.value(
value: _cubit..setSelectedFilePath(
widget.certificate?.certificateUrl,
),
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
listener: (BuildContext context, CertificateUploadState state) {
if (state.status == CertificateUploadStatus.success) {
@@ -155,7 +159,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
message: t.staff_certificates.upload_modal.success_snackbar,
type: UiSnackbarType.success,
);
Modular.to.pop(); // Returns to certificates list
Modular.to.popSafe(); // Returns to certificates list
} else if (state.status == CertificateUploadStatus.failure) {
UiSnackbar.show(
context,
@@ -170,69 +174,23 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
title:
widget.certificate?.name ??
t.staff_certificates.upload_modal.title,
onLeadingPressed: () => Modular.to.pop(),
onLeadingPressed: () => Modular.to.popSafe(),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PdfFileTypesBanner(
PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner,
),
const SizedBox(height: UiConstants.space6),
// Name Field
Text(
t.staff_certificates.upload_modal.name_label,
style: UiTypography.body2m.textPrimary,
),
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,
),
),
CertificateMetadataFields(
nameController: _nameController,
issuerController: _issuerController,
numberController: _numberController,
isNewCertificate: _isNewCertificate,
),
const SizedBox(height: UiConstants.space6),
@@ -240,44 +198,9 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
const SizedBox(height: UiConstants.space6),
// Expiry Date Field
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
ExpiryDateField(
selectedDate: _selectedExpiryDate,
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),
@@ -287,8 +210,8 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
_FileSelector(
selectedFilePath: _selectedFilePath,
FileSelector(
selectedFilePath: state.selectedFilePath,
onTap: _pickFile,
),
],
@@ -297,110 +220,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
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 &&
_nameController.text.isNotEmpty)
? () {
final String? err = _validatePdfFile(
context,
_selectedFilePath!,
);
if (err != null) {
UiSnackbar.show(
context,
message: err,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(
UiConstants.space4,
),
);
return;
}
BlocProvider.of<CertificateUploadCubit>(
context,
).uploadCertificate(
UploadCertificateParams(
certificationType: _selectedType!,
name: _nameController.text,
filePath: _selectedFilePath!,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,
),
);
}
: null,
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,
),
child: CertificateUploadActions(
isAttested: state.isAttested,
isFormValid: state.selectedFilePath != null &&
state.isAttested &&
_nameController.text.isNotEmpty,
isUploading: state.status == CertificateUploadStatus.uploading,
hasExistingCertificate: widget.certificate != null,
onUploadPressed: () {
BlocProvider.of<CertificateUploadCubit>(context)
.uploadCertificate(
UploadCertificateParams(
certificationType: _selectedType!,
name: _nameController.text,
filePath: state.selectedFilePath!,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,
),
),
// 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,
),
),
),
),
),
],
],
);
},
onRemovePressed: () => _showRemoveConfirmation(context),
),
),
),
@@ -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;
return Scaffold(
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
appBar: UiAppBar(
title: t.staff_certificates.title,
showBackButton: true,
),
body: SingleChildScrollView(
child: Column(
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:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
class CertificatesHeader extends StatelessWidget {
const CertificatesHeader({
super.key,
required this.completedCount,
@@ -16,8 +14,12 @@ class CertificatesHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Prevent division by zero
final double progressValue = totalCount == 0 ? 0 : completedCount / totalCount;
final int progressPercent = totalCount == 0 ? 0 : (progressValue * 100).round();
final double progressValue = totalCount == 0
? 0
: completedCount / totalCount;
final int progressPercent = totalCount == 0
? 0
: (progressValue * 100).round();
return Container(
padding: const EdgeInsets.fromLTRB(
@@ -32,39 +34,13 @@ class CertificatesHeader extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
UiColors.primary,
UiColors.primary.withValues(alpha: 0.8),
UiColors.primary.withValues(alpha: 0.5),
],
),
),
child: Column(
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(
children: <Widget>[
SizedBox(
@@ -101,7 +77,9 @@ class CertificatesHeader extends StatelessWidget {
const SizedBox(height: UiConstants.space1),
Text(
t.staff_certificates.progress.verified_count(
completed: completedCount, total: totalCount),
completed: completedCount,
total: totalCount,
),
style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
),

View File

@@ -50,21 +50,11 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
);
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 VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: verificationType,
type: 'government_id',
subjectType: 'worker',
subjectId: staffId,
rules: <String, dynamic>{
@@ -75,7 +65,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
// 4. Update/Create StaffDocument in Data Connect
await _service.getStaffRepository().upsertStaffDocument(
documentId: documentId,
documentUrl: uploadRes.fileUri,
documentUrl: signedUrlRes.signedUrl,
status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId,
);

View File

@@ -19,6 +19,11 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
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.
///
/// Requires [state.isAttested] to be true before proceeding.

View File

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

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.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_file_selector.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.
///
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
/// file selection → attestation → submit → poll for result.
class DocumentUploadPage extends StatefulWidget {
class DocumentUploadPage extends StatelessWidget {
const DocumentUploadPage({
super.key,
required this.document,
@@ -31,64 +30,17 @@ class DocumentUploadPage extends StatefulWidget {
/// Optional URL of an already-uploaded document.
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
Widget build(BuildContext context) {
if (widget.initialUrl != null) {
_selectedFilePath = widget.initialUrl;
}
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>(
listener: (BuildContext context, DocumentUploadState state) {
if (state.status == DocumentUploadStatus.success) {
@@ -109,8 +61,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
builder: (BuildContext context, DocumentUploadState state) {
return Scaffold(
appBar: UiAppBar(
title: widget.document.name,
subtitle: widget.document.description,
title: document.name,
subtitle: document.description,
onLeadingPressed: () => Modular.to.toDocuments(),
),
body: SingleChildScrollView(
@@ -118,13 +70,16 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PdfFileTypesBanner(
PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner,
),
const SizedBox(height: UiConstants.space6),
DocumentFileSelector(
selectedFilePath: _selectedFilePath,
onTap: _pickFile,
selectedFilePath: state.selectedFilePath,
onFileSelected: (String path) {
BlocProvider.of<DocumentUploadCubit>(context)
.setSelectedFilePath(path);
},
),
],
),
@@ -150,27 +105,13 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
DocumentUploadFooter(
isUploading:
state.status == DocumentUploadStatus.uploading,
canSubmit: _selectedFilePath != null && state.isAttested,
canSubmit: state.selectedFilePath != null && state.isAttested,
onSubmit: () {
final String? err = _validatePdfFile(
context,
_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!,
);
BlocProvider.of<DocumentUploadCubit>(context)
.uploadDocument(
document.documentId,
state.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:flutter/material.dart';
// ignore: depend_on_referenced_packages
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';
@@ -9,33 +13,89 @@ import 'document_selected_card.dart';
///
/// Shows the selected file name when a file has been chosen, or an
/// upload icon with a prompt when no file is selected yet.
class DocumentFileSelector extends StatelessWidget {
class DocumentFileSelector extends StatefulWidget {
const DocumentFileSelector({
super.key,
required this.onTap,
this.onFileSelected,
this.selectedFilePath,
});
/// Called when the user taps the selector to pick a file.
final VoidCallback onTap;
/// Called when a file is successfully selected and validated.
final Function(String)? onFileSelected;
/// The local path of the currently selected file, or null if none chosen.
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
Widget build(BuildContext context) {
if (_hasFile) {
return InkWell(
onTap: onTap,
onTap: _pickFile,
borderRadius: UiConstants.radiusLg,
child: DocumentSelectedCard(selectedFilePath: selectedFilePath!),
child: DocumentSelectedCard(selectedFilePath: _selectedFilePath!),
);
}
return InkWell(
onTap: onTap,
onTap: _pickFile,
borderRadius: UiConstants.radiusLg,
child: Container(
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:design_system/design_system.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:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/i9/form_i9_cubit.dart';
@@ -18,11 +20,56 @@ class FormI9Page extends StatefulWidget {
class _FormI9PageState extends State<FormI9Page> {
final List<String> _usStates = <String>[
'AL', 'AK', 'AZ', 'AR', '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'
'AL',
'AK',
'AZ',
'AR',
'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
@@ -36,10 +83,19 @@ class _FormI9PageState extends State<FormI9Page> {
}
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': 'Citizenship Status', 'subtitle': 'Work authorization verification'},
<String, String>{'title': 'Review & Sign', 'subtitle': 'Confirm your information'},
<String, String>{
'title': 'Citizenship Status',
'subtitle': 'Work authorization verification',
},
<String, String>{
'title': 'Review & Sign',
'subtitle': 'Confirm your information',
},
];
bool _canProceed(FormI9State state) {
@@ -77,13 +133,27 @@ class _FormI9PageState extends State<FormI9Page> {
@override
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>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_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},
<String, String>{
'title': i18n.steps.personal,
'subtitle': i18n.steps.personal_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(
@@ -95,7 +165,9 @@ class _FormI9PageState extends State<FormI9Page> {
} else if (state.status == FormI9Status.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
message: translateErrorKey(
state.errorMessage ?? 'An error occurred',
),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(
left: UiConstants.space4,
@@ -106,7 +178,8 @@ class _FormI9PageState extends State<FormI9Page> {
}
},
builder: (BuildContext context, FormI9State state) {
if (state.status == FormI9Status.success) return _buildSuccessView(i18n);
if (state.status == FormI9Status.success)
return _buildSuccessView(i18n);
return Scaffold(
backgroundColor: UiColors.background,
@@ -175,7 +248,7 @@ class _FormI9PageState extends State<FormI9Page> {
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Modular.to.pop(true),
onPressed: () => Modular.to.popSafe(true),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
@@ -187,7 +260,11 @@ class _FormI9PageState extends State<FormI9Page> {
),
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(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.pop(),
onTap: () => Modular.to.popSafe(),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
@@ -229,10 +306,7 @@ class _FormI9PageState extends State<FormI9Page> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.title,
style: UiTypography.headline4m.white,
),
Text(i18n.title, style: UiTypography.headline4m.white),
Text(
i18n.subtitle,
style: UiTypography.body3r.copyWith(
@@ -245,10 +319,9 @@ class _FormI9PageState extends State<FormI9Page> {
),
const SizedBox(height: UiConstants.space6),
Row(
children: steps
.asMap()
.entries
.map((MapEntry<int, Map<String, String>> entry) {
children: steps.asMap().entries.map((
MapEntry<int, Map<String, String>> entry,
) {
final int idx = entry.key;
final bool isLast = idx == steps.length - 1;
return Expanded(
@@ -384,7 +457,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.first_name,
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,
),
),
@@ -393,7 +467,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.last_name,
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,
),
),
@@ -406,7 +481,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.middle_initial,
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,
),
),
@@ -416,7 +492,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.other_last_names,
value: state.otherLastNames,
onChanged: (String val) => context.read<FormI9Cubit>().otherLastNamesChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().otherLastNamesChanged(val),
placeholder: i18n.fields.maiden_name,
),
),
@@ -426,7 +503,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.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,
keyboardType: TextInputType.datetime,
),
@@ -446,7 +524,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.email,
value: state.email,
onChanged: (String val) => context.read<FormI9Cubit>().emailChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().emailChanged(val),
keyboardType: TextInputType.emailAddress,
placeholder: i18n.fields.hints.email,
),
@@ -454,7 +533,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.phone,
value: state.phone,
onChanged: (String val) => context.read<FormI9Cubit>().phoneChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().phoneChanged(val),
keyboardType: TextInputType.phone,
placeholder: i18n.fields.hints.phone,
),
@@ -472,14 +552,16 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.address_long,
value: state.address,
onChanged: (String val) => context.read<FormI9Cubit>().addressChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().addressChanged(val),
placeholder: i18n.fields.hints.address,
),
const SizedBox(height: UiConstants.space4),
_buildTextField(
i18n.fields.apt,
value: state.aptNumber,
onChanged: (String val) => context.read<FormI9Cubit>().aptNumberChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().aptNumberChanged(val),
placeholder: i18n.fields.hints.apt,
),
const SizedBox(height: UiConstants.space4),
@@ -490,7 +572,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.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,
),
),
@@ -541,7 +624,8 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.zip,
value: state.zipCode,
onChanged: (String val) => context.read<FormI9Cubit>().zipCodeChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().zipCodeChanged(val),
placeholder: i18n.fields.hints.zip,
keyboardType: TextInputType.number,
),
@@ -557,24 +641,11 @@ class _FormI9PageState extends State<FormI9Page> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.fields.attestation,
style: UiTypography.body2m.textPrimary,
),
Text(i18n.fields.attestation, style: UiTypography.body2m.textPrimary),
const SizedBox(height: UiConstants.space6),
_buildRadioOption(
context,
state,
'CITIZEN',
i18n.fields.citizen,
),
_buildRadioOption(context, state, 'CITIZEN', i18n.fields.citizen),
const SizedBox(height: UiConstants.space3),
_buildRadioOption(
context,
state,
'NONCITIZEN',
i18n.fields.noncitizen,
),
_buildRadioOption(context, state, 'NONCITIZEN', i18n.fields.noncitizen),
const SizedBox(height: UiConstants.space3),
_buildRadioOption(
context,
@@ -587,7 +658,8 @@ class _FormI9PageState extends State<FormI9Page> {
child: _buildTextField(
i18n.fields.uscis_number_label,
value: state.uscisNumber,
onChanged: (String val) => context.read<FormI9Cubit>().uscisNumberChanged(val),
onChanged: (String val) =>
context.read<FormI9Cubit>().uscisNumberChanged(val),
placeholder: i18n.fields.hints.uscis,
),
)
@@ -607,19 +679,25 @@ class _FormI9PageState extends State<FormI9Page> {
_buildTextField(
i18n.fields.admission_number,
value: state.admissionNumber,
onChanged: (String val) => context.read<FormI9Cubit>().admissionNumberChanged(val),
onChanged: (String val) => context
.read<FormI9Cubit>()
.admissionNumberChanged(val),
),
const SizedBox(height: UiConstants.space3),
_buildTextField(
i18n.fields.passport,
value: state.passportNumber,
onChanged: (String val) => context.read<FormI9Cubit>().passportNumberChanged(val),
onChanged: (String val) => context
.read<FormI9Cubit>()
.passportNumberChanged(val),
),
const SizedBox(height: UiConstants.space3),
_buildTextField(
i18n.fields.country,
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),
Expanded(
child: Text(
label,
style: UiTypography.body2m.textPrimary,
),
child: Text(label, style: UiTypography.body2m.textPrimary),
),
],
),
@@ -704,8 +779,14 @@ class _FormI9PageState extends State<FormI9Page> {
style: UiTypography.headline4m.copyWith(fontSize: 14),
),
const SizedBox(height: UiConstants.space3),
_buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'),
_buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'),
_buildSummaryRow(
i18n.fields.summary_name,
'${state.firstName} ${state.lastName}',
),
_buildSummaryRow(
i18n.fields.summary_address,
'${state.address}, ${state.city}',
),
_buildSummaryRow(
i18n.fields.summary_ssn,
'***-**-${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),
),
const SizedBox(height: UiConstants.space4),
Text(
i18n.fields.date_label,
style: UiTypography.body3m.textSecondary,
),
Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
const SizedBox(height: UiConstants.space1 + 2),
Container(
width: double.infinity,
@@ -811,10 +889,7 @@ class _FormI9PageState extends State<FormI9Page> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
label,
style: UiTypography.body2r.textSecondary,
),
Text(label, style: UiTypography.body2r.textSecondary),
Expanded(
child: Text(
value,
@@ -828,7 +903,9 @@ class _FormI9PageState extends State<FormI9Page> {
}
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) {
case 'CITIZEN':
return i18n.status_us_citizen;
@@ -848,7 +925,9 @@ class _FormI9PageState extends State<FormI9Page> {
FormI9State state,
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(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -883,10 +962,7 @@ class _FormI9PageState extends State<FormI9Page> {
color: UiColors.textPrimary,
),
const SizedBox(width: UiConstants.space2),
Text(
i18n.back,
style: UiTypography.body2r.textPrimary,
),
Text(i18n.back, style: UiTypography.body2r.textPrimary),
],
),
),
@@ -895,8 +971,8 @@ class _FormI9PageState extends State<FormI9Page> {
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: (
_canProceed(state) &&
onPressed:
(_canProceed(state) &&
state.status != FormI9Status.submitting)
? () => _handleNext(context, state.currentStep)
: null,
@@ -931,7 +1007,11 @@ class _FormI9PageState extends State<FormI9Page> {
),
if (state.currentStep < steps.length - 1) ...<Widget>[
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:design_system/design_system.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:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'},
<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'},
];
@@ -116,23 +121,41 @@ class _FormW4PageState extends State<FormW4Page> {
context.read<FormW4Cubit>().previousStep();
}
int _totalCredits(FormW4State state) {
return (state.qualifyingChildren * 2000) +
(state.otherDependents * 500);
return (state.qualifyingChildren * 2000) + (state.otherDependents * 500);
}
@override
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>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')},
<String, String>{'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')},
<String, String>{
'title': i18n.steps.personal,
'subtitle': i18n.step_label(current: '1', total: '5'),
},
<String, String>{
'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(
@@ -144,7 +167,9 @@ class _FormW4PageState extends State<FormW4Page> {
} else if (state.status == FormW4Status.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
message: translateErrorKey(
state.errorMessage ?? 'An error occurred',
),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(
left: UiConstants.space4,
@@ -155,7 +180,8 @@ class _FormW4PageState extends State<FormW4Page> {
}
},
builder: (BuildContext context, FormW4State state) {
if (state.status == FormW4Status.success) return _buildSuccessView(i18n);
if (state.status == FormW4Status.success)
return _buildSuccessView(i18n);
return Scaffold(
backgroundColor: UiColors.background,
@@ -224,7 +250,7 @@ class _FormW4PageState extends State<FormW4Page> {
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Modular.to.pop(true),
onPressed: () => Modular.to.popSafe(true),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
@@ -267,7 +293,7 @@ class _FormW4PageState extends State<FormW4Page> {
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.pop(),
onTap: () => Modular.to.popSafe(),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
@@ -278,10 +304,7 @@ class _FormW4PageState extends State<FormW4Page> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.title,
style: UiTypography.headline4m.white,
),
Text(i18n.title, style: UiTypography.headline4m.white),
Text(
i18n.subtitle,
style: UiTypography.body3r.copyWith(
@@ -294,10 +317,9 @@ class _FormW4PageState extends State<FormW4Page> {
),
const SizedBox(height: UiConstants.space6),
Row(
children: steps
.asMap()
.entries
.map((MapEntry<int, Map<String, String>> entry) {
children: steps.asMap().entries.map((
MapEntry<int, Map<String, String>> entry,
) {
final int idx = entry.key;
final bool isLast = idx == steps.length - 1;
return Expanded(
@@ -434,7 +456,8 @@ class _FormW4PageState extends State<FormW4Page> {
child: _buildTextField(
i18n.fields.first_name,
value: state.firstName,
onChanged: (String val) => context.read<FormW4Cubit>().firstNameChanged(val),
onChanged: (String val) =>
context.read<FormW4Cubit>().firstNameChanged(val),
placeholder: i18n.fields.placeholder_john,
),
),
@@ -443,7 +466,8 @@ class _FormW4PageState extends State<FormW4Page> {
child: _buildTextField(
i18n.fields.last_name,
value: state.lastName,
onChanged: (String val) => context.read<FormW4Cubit>().lastNameChanged(val),
onChanged: (String val) =>
context.read<FormW4Cubit>().lastNameChanged(val),
placeholder: i18n.fields.placeholder_smith,
),
),
@@ -465,14 +489,16 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField(
i18n.fields.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,
),
const SizedBox(height: UiConstants.space4),
_buildTextField(
i18n.fields.city_state_zip,
value: state.cityStateZip,
onChanged: (String val) => context.read<FormW4Cubit>().cityStateZipChanged(val),
onChanged: (String val) =>
context.read<FormW4Cubit>().cityStateZipChanged(val),
placeholder: i18n.fields.placeholder_csz,
),
],
@@ -506,21 +532,9 @@ class _FormW4PageState extends State<FormW4Page> {
),
),
const SizedBox(height: UiConstants.space6),
_buildRadioOption(
context,
state,
'SINGLE',
i18n.fields.single,
null,
),
_buildRadioOption(context, state, 'SINGLE', i18n.fields.single, null),
const SizedBox(height: UiConstants.space3),
_buildRadioOption(
context,
state,
'MARRIED',
i18n.fields.married,
null,
),
_buildRadioOption(context, state, 'MARRIED', i18n.fields.married, null),
const SizedBox(height: UiConstants.space3),
_buildRadioOption(
context,
@@ -573,16 +587,10 @@ class _FormW4PageState extends State<FormW4Page> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: UiTypography.body2m.textPrimary,
),
Text(label, style: UiTypography.body2m.textPrimary),
if (subLabel != null) ...<Widget>[
const SizedBox(height: 4),
Text(
subLabel,
style: UiTypography.body3r.textSecondary,
),
Text(subLabel, style: UiTypography.body3r.textSecondary),
],
],
),
@@ -609,11 +617,7 @@ class _FormW4PageState extends State<FormW4Page> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(
UiIcons.help,
color: UiColors.accent,
size: 20,
),
const Icon(UiIcons.help, color: UiColors.accent, size: 20),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
@@ -636,8 +640,9 @@ class _FormW4PageState extends State<FormW4Page> {
),
const SizedBox(height: UiConstants.space6),
GestureDetector(
onTap: () =>
context.read<FormW4Cubit>().multipleJobsChanged(!state.multipleJobs),
onTap: () => context.read<FormW4Cubit>().multipleJobsChanged(
!state.multipleJobs,
),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -654,10 +659,14 @@ class _FormW4PageState extends State<FormW4Page> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: state.multipleJobs ? UiColors.primary : UiColors.bgPopup,
color: state.multipleJobs
? UiColors.primary
: UiColors.bgPopup,
borderRadius: UiConstants.radiusMd,
border: Border.all(
color: state.multipleJobs ? UiColors.primary : UiColors.border,
color: state.multipleJobs
? UiColors.primary
: UiColors.border,
),
),
child: state.multipleJobs
@@ -741,7 +750,8 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.children_under_17,
i18n.fields.children_each,
(FormW4State s) => s.qualifyingChildren,
(int val) => context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
(int val) =>
context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
@@ -753,7 +763,8 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.other_dependents,
i18n.fields.other_each,
(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(
'\$${_totalCredits(state)}',
style: UiTypography.body2b.textSuccess.copyWith(
fontSize: 18,
),
style: UiTypography.body2b.textSuccess.copyWith(fontSize: 18),
),
],
),
@@ -802,22 +811,14 @@ class _FormW4PageState extends State<FormW4Page> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
label,
style: UiTypography.body2m,
),
),
Expanded(child: Text(label, style: UiTypography.body2m)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
),
child: Text(
badge,
style: UiTypography.footnote2b.textSuccess,
),
child: Text(badge, style: UiTypography.footnote2b.textSuccess),
),
],
),
@@ -839,10 +840,7 @@ class _FormW4PageState extends State<FormW4Page> {
),
),
),
_buildCircleBtn(
UiIcons.add,
() => onChanged(value + 1),
),
_buildCircleBtn(UiIcons.add, () => onChanged(value + 1)),
],
),
],
@@ -881,7 +879,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField(
i18n.fields.other_income,
value: state.otherIncome,
onChanged: (String val) => context.read<FormW4Cubit>().otherIncomeChanged(val),
onChanged: (String val) =>
context.read<FormW4Cubit>().otherIncomeChanged(val),
placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number,
),
@@ -896,7 +895,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField(
i18n.fields.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,
keyboardType: TextInputType.number,
),
@@ -911,7 +911,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildTextField(
i18n.fields.extra_withholding,
value: state.extraWithholding,
onChanged: (String val) => context.read<FormW4Cubit>().extraWithholdingChanged(val),
onChanged: (String val) =>
context.read<FormW4Cubit>().extraWithholdingChanged(val),
placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number,
),
@@ -1019,10 +1020,7 @@ class _FormW4PageState extends State<FormW4Page> {
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
),
const SizedBox(height: UiConstants.space4),
Text(
i18n.fields.date_label,
style: UiTypography.body3m.textSecondary,
),
Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
const SizedBox(height: 6),
Container(
width: double.infinity,
@@ -1050,10 +1048,7 @@ class _FormW4PageState extends State<FormW4Page> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
label,
style: UiTypography.body2r.textSecondary,
),
Text(label, style: UiTypography.body2r.textSecondary),
Text(
value,
style: UiTypography.body2m.copyWith(
@@ -1066,7 +1061,9 @@ class _FormW4PageState extends State<FormW4Page> {
}
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) {
case 'SINGLE':
return i18n.status_single;
@@ -1084,7 +1081,9 @@ class _FormW4PageState extends State<FormW4Page> {
FormW4State state,
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(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -1131,8 +1130,8 @@ class _FormW4PageState extends State<FormW4Page> {
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: (
_canProceed(state) &&
onPressed:
(_canProceed(state) &&
state.status != FormW4Status.submitting)
? () => _handleNext(context, state.currentStep)
: null,
@@ -1167,7 +1166,11 @@ class _FormW4PageState extends State<FormW4Page> {
),
if (state.currentStep < steps.length - 1) ...<Widget>[
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_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/tax_forms/tax_forms_cubit.dart';
import '../blocs/tax_forms/tax_forms_state.dart';
@@ -13,39 +14,10 @@ class TaxFormsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: UiColors.primary,
elevation: 0,
leading: IconButton(
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),
),
),
),
],
),
),
),
appBar: const UiAppBar(
title: 'Tax Documents',
subtitle: 'Complete required forms to start working',
showBackButton: true,
),
body: BlocProvider<TaxFormsCubit>(
create: (BuildContext context) {
@@ -64,7 +36,9 @@ class TaxFormsPage extends StatelessWidget {
if (state.status == TaxFormsStatus.failure) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
@@ -81,10 +55,12 @@ class TaxFormsPage extends StatelessWidget {
vertical: UiConstants.space6,
),
child: Column(
spacing: UiConstants.space6,
spacing: UiConstants.space4,
children: <Widget>[
_buildProgressOverview(state.forms),
...state.forms.map((TaxForm form) => _buildFormCard(context, form)),
...state.forms.map(
(TaxForm form) => _buildFormCard(context, form),
),
_buildInfoCard(),
],
),
@@ -118,10 +94,7 @@ class TaxFormsPage extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'Document Progress',
style: UiTypography.body2m.textPrimary,
),
Text('Document Progress', style: UiTypography.body2m.textPrimary),
Text(
'$completedCount/$totalCount',
style: UiTypography.body2m.textSecondary,
@@ -150,12 +123,18 @@ class TaxFormsPage extends StatelessWidget {
return GestureDetector(
onTap: () async {
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) {
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
}
} 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) {
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
}
@@ -245,10 +224,7 @@ class TaxFormsPage extends StatelessWidget {
color: UiColors.textSuccess,
),
const SizedBox(width: UiConstants.space1),
Text(
'Completed',
style: UiTypography.footnote2b.textSuccess,
),
Text('Completed', style: UiTypography.footnote2b.textSuccess),
],
),
);
@@ -267,10 +243,7 @@ class TaxFormsPage extends StatelessWidget {
children: <Widget>[
const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning),
const SizedBox(width: UiConstants.space1),
Text(
'In Progress',
style: UiTypography.footnote2b.textWarning,
),
Text('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_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
// ignore: depend_on_referenced_packages
import '../blocs/bank_account_cubit.dart';
import '../blocs/bank_account_state.dart';
import '../widgets/account_card.dart';
import '../widgets/add_account_form.dart';
import '../widgets/security_notice.dart';
class BankAccountPage extends StatelessWidget {
const BankAccountPage({super.key});
@@ -26,23 +28,7 @@ class BankAccountPage extends StatelessWidget {
final dynamic strings = t.staff.profile.bank_account_page;
return Scaffold(
backgroundColor: UiColors.background,
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),
),
),
appBar: UiAppBar(title: strings.title, showBackButton: true),
body: BlocConsumer<BankAccountCubit, BankAccountState>(
bloc: cubit,
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
},
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());
}
@@ -74,7 +61,9 @@ class BankAccountPage extends StatelessWidget {
? translateErrorKey(state.errorMessage!)
: 'Error',
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildSecurityNotice(strings),
const SizedBox(height: UiConstants.space6),
Text(
strings.linked_accounts,
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary),
),
const SizedBox(height: UiConstants.space3),
...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type
SecurityNotice(strings: strings),
if (state.accounts.isEmpty) ...<Widget>[
const SizedBox(height: UiConstants.space32),
const UiEmptyState(
icon: UiIcons.building,
title: 'No accounts yet',
description:
'Add your first bank account to get started',
),
] else ...<Widget>[
const SizedBox(height: UiConstants.space4),
...state.accounts.map<Widget>(
(StaffBankAccount account) =>
AccountCard(account: account, strings: strings),
),
],
// Add extra padding at bottom
const SizedBox(height: UiConstants.space20),
],
@@ -121,17 +117,23 @@ class BankAccountPage extends StatelessWidget {
backgroundColor: UiColors.transparent,
child: AddAccountForm(
strings: strings,
onSubmit: (String bankName, String routing, String account, String type) {
cubit.addAccount(
bankName: bankName,
routingNumber: routing,
accountNumber: account,
type: type,
);
Modular.to.pop();
},
onSubmit:
(
String bankName,
String routing,
String account,
String type,
) {
cubit.addAccount(
bankName: bankName,
routingNumber: routing,
accountNumber: account,
type: type,
);
Modular.to.popSafe();
},
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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_core/core.dart';
import '../blocs/time_card_bloc.dart';
import '../widgets/month_selector.dart';
import '../widgets/shift_history_list.dart';
@@ -18,11 +19,12 @@ class TimeCardPage extends StatefulWidget {
}
class _TimeCardPageState extends State<TimeCardPage> {
final TimeCardBloc _bloc = Modular.get<TimeCardBloc>();
late final TimeCardBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Modular.get<TimeCardBloc>();
_bloc.add(LoadTimeCards(DateTime.now()));
}
@@ -32,22 +34,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: UiColors.bgPrimary,
appBar: AppBar(
backgroundColor: UiColors.bgPopup,
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),
),
appBar: UiAppBar(
title: t.staff_time_card.title,
showBackButton: true,
),
body: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (BuildContext context, TimeCardState state) {
@@ -69,7 +58,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
child: Text(
translateErrorKey(state.message),
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>[
MonthSelector(
selectedDate: state.selectedMonth,
onPreviousMonth: () => _bloc.add(ChangeMonth(
DateTime(state.selectedMonth.year, state.selectedMonth.month - 1),
)),
onNextMonth: () => _bloc.add(ChangeMonth(
DateTime(state.selectedMonth.year, state.selectedMonth.month + 1),
)),
onPreviousMonth: () => _bloc.add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month - 1,
),
),
),
onNextMonth: () => _bloc.add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month + 1,
),
),
),
),
const SizedBox(height: UiConstants.space6),
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_state.dart';
import '../widgets/attire_capture_page/file_types_banner.dart';
import '../widgets/attire_capture_page/footer_section.dart';
import '../widgets/attire_capture_page/image_preview_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),
title: Text(t.common.gallery),
onTap: () {
Modular.to.pop();
Modular.to.popSafe();
_onGallery(context);
},
),
@@ -143,7 +144,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
leading: const Icon(Icons.camera_alt),
title: Text(t.common.camera),
onTap: () {
Modular.to.pop();
Modular.to.popSafe();
_onCamera(context);
},
),
@@ -215,10 +216,16 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
String _getStatusText(bool hasUploadedPhoto) {
return switch (widget.item.verificationStatus) {
AttireVerificationStatus.approved => t.staff_profile_attire.capture.approved,
AttireVerificationStatus.rejected => 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,
AttireVerificationStatus.approved =>
t.staff_profile_attire.capture.approved,
AttireVerificationStatus.rejected =>
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),
child: Column(
children: <Widget>[
_FileTypesBanner(
message: t.staff_profile_attire.upload_file_types_banner,
FileTypesBanner(
message: t
.staff_profile_attire
.upload_file_types_banner,
),
const SizedBox(height: UiConstants.space4),
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_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:krow_core/core.dart';
import 'attire_upload_buttons.dart';
@@ -98,7 +99,7 @@ class FooterSection extends StatelessWidget {
text: 'Submit Image',
onPressed: () {
if (updatedItem != null) {
Modular.to.pop(updatedItem);
Modular.to.popSafe(updatedItem);
}
},
),

View File

@@ -7,35 +7,10 @@ class AttireInfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
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,
),
],
),
),
],
),
return UiNoticeBanner(
icon: UiIcons.shirt,
title: t.staff_profile_attire.info_card.title,
description: t.staff_profile_attire.info_card.description,
);
}
}

View File

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

View File

@@ -6,16 +6,9 @@ class EmergencyContactInfoBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
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.',
style: UiTypography.body2r.textPrimary,
),
return UiNoticeBanner(
title:
'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.',
);
}
}

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