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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -119,7 +119,6 @@ vite.config.ts.timestamp-*
|
|||||||
# Android
|
# Android
|
||||||
.gradle/
|
.gradle/
|
||||||
**/android/app/libs/
|
**/android/app/libs/
|
||||||
**/android/key.properties
|
|
||||||
**/android/local.properties
|
**/android/local.properties
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
@@ -193,3 +192,4 @@ AGENTS.md
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
GEMINI.md
|
GEMINI.md
|
||||||
TASKS.md
|
TASKS.md
|
||||||
|
\n# Android Signing (Secure)\n**.jks\n**key.properties
|
||||||
|
|||||||
@@ -26,7 +26,60 @@ The project is organized into modular packages to ensure separation of concerns
|
|||||||
### 1. Prerequisites
|
### 1. Prerequisites
|
||||||
Ensure you have the Flutter SDK installed and configured.
|
Ensure you have the Flutter SDK installed and configured.
|
||||||
|
|
||||||
### 2. Initial Setup
|
### 2. Android Keystore Setup (Required for Release Builds)
|
||||||
|
|
||||||
|
To build release APKs/AABs for Android, you need the signing keystores. The keystore configuration (`key.properties`) is committed to the repository, but the actual keystore files are **not** for security reasons.
|
||||||
|
|
||||||
|
#### For Local Development (First-time Setup)
|
||||||
|
|
||||||
|
Contact your team lead to obtain the keystore files:
|
||||||
|
- `krow_with_us_client_dev.jks` - Client app signing keystore
|
||||||
|
- `krow_with_us_staff_dev.jks` - Staff app signing keystore
|
||||||
|
|
||||||
|
Once you have the keystores, copy them to the respective app directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy keystores to their locations
|
||||||
|
cp krow_with_us_client_dev.jks apps/mobile/apps/client/android/app/
|
||||||
|
cp krow_with_us_staff_dev.jks apps/mobile/apps/staff/android/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `key.properties` configuration files are already in the repository:
|
||||||
|
- `apps/mobile/apps/client/android/key.properties`
|
||||||
|
- `apps/mobile/apps/staff/android/key.properties`
|
||||||
|
|
||||||
|
No manual property file creation is needed — just place the `.jks` files in the correct locations.
|
||||||
|
|
||||||
|
#### For CI/CD (CodeMagic)
|
||||||
|
|
||||||
|
CodeMagic uses a native keystore management system. Follow these steps:
|
||||||
|
|
||||||
|
**Step 1: Upload Keystores to CodeMagic**
|
||||||
|
1. Go to **CodeMagic Team Settings** → **Code signing identities** → **Android keystores**
|
||||||
|
2. Upload the keystore files with these **Reference names** (important!):
|
||||||
|
- `krow_client_dev` (for dev builds)
|
||||||
|
- `krow_client_staging` (for staging builds)
|
||||||
|
- `krow_client_prod` (for production builds)
|
||||||
|
- `krow_staff_dev` (for dev builds)
|
||||||
|
- `krow_staff_staging` (for staging builds)
|
||||||
|
- `krow_staff_prod` (for production builds)
|
||||||
|
3. When uploading, enter the keystore password, key alias, and key password for each keystore
|
||||||
|
|
||||||
|
**Step 2: Automatic Environment Variables**
|
||||||
|
CodeMagic automatically injects the following environment variables based on the keystore reference:
|
||||||
|
- `CM_KEYSTORE_PATH_CLIENT` / `CM_KEYSTORE_PATH_STAFF` - Path to the keystore file
|
||||||
|
- `CM_KEYSTORE_PASSWORD_CLIENT` / `CM_KEYSTORE_PASSWORD_STAFF` - Keystore password
|
||||||
|
- `CM_KEY_ALIAS_CLIENT` / `CM_KEY_ALIAS_STAFF` - Key alias
|
||||||
|
- `CM_KEY_PASSWORD_CLIENT` / `CM_KEY_PASSWORD_STAFF` - Key password
|
||||||
|
|
||||||
|
**Step 3: Build Configuration**
|
||||||
|
The `build.gradle.kts` files are already configured to:
|
||||||
|
- Use CodeMagic environment variables when running in CI (`CI=true`)
|
||||||
|
- Fall back to `key.properties` for local development
|
||||||
|
|
||||||
|
Reference: [CodeMagic Android Signing Documentation](https://docs.codemagic.io/yaml-code-signing/signing-android/)
|
||||||
|
|
||||||
|
### 3. Initial Setup
|
||||||
Run the following command from the **project root** to install Melos, bootstrap all packages, generate localization files, and generate the Firebase Data Connect SDK:
|
Run the following command from the **project root** to install Melos, bootstrap all packages, generate localization files, and generate the Firebase Data Connect SDK:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -42,7 +95,7 @@ This command will:
|
|||||||
|
|
||||||
**Note:** The Firebase Data Connect SDK files (`dataconnect_generated/`) are auto-generated and not committed to the repository. They will be regenerated automatically when you run `make mobile-install` or any mobile development commands.
|
**Note:** The Firebase Data Connect SDK files (`dataconnect_generated/`) are auto-generated and not committed to the repository. They will be regenerated automatically when you run `make mobile-install` or any mobile development commands.
|
||||||
|
|
||||||
### 3. Running the Apps
|
### 4. Running the Apps
|
||||||
You can run the applications using Melos scripts or through the `Makefile`:
|
You can run the applications using Melos scripts or through the `Makefile`:
|
||||||
|
|
||||||
First, find your device ID:
|
First, find your device ID:
|
||||||
|
|||||||
3
apps/mobile/apps/client/android/.gitignore
vendored
3
apps/mobile/apps/client/android/.gitignore
vendored
@@ -7,8 +7,7 @@ gradle-wrapper.jar
|
|||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore files.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propertiesFile = rootProject.file("key.properties")
|
||||||
|
if (propertiesFile.exists()) {
|
||||||
|
load(propertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.krowwithus.client"
|
namespace = "com.krowwithus.client"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -47,11 +55,29 @@ android {
|
|||||||
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (System.getenv()["CI"] == "true") {
|
||||||
|
// CodeMagic CI environment
|
||||||
|
storeFile = file(System.getenv()["CM_KEYSTORE_PATH_CLIENT"] ?: "")
|
||||||
|
storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_CLIENT"]
|
||||||
|
keyAlias = System.getenv()["CM_KEY_ALIAS_CLIENT"]
|
||||||
|
keyPassword = System.getenv()["CM_KEY_PASSWORD_CLIENT"]
|
||||||
|
} else {
|
||||||
|
// Local development environment
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,11 @@
|
|||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
{
|
{
|
||||||
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com",
|
"client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
"android_info": {
|
"android_info": {
|
||||||
"package_name": "com.krowwithus.client",
|
"package_name": "com.krowwithus.client",
|
||||||
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280"
|
"certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,11 +130,11 @@
|
|||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
{
|
{
|
||||||
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
|
"client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
"android_info": {
|
"android_info": {
|
||||||
"package_name": "com.krowwithus.staff",
|
"package_name": "com.krowwithus.staff",
|
||||||
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d"
|
"certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
9
apps/mobile/apps/client/android/key.properties
Normal file
9
apps/mobile/apps/client/android/key.properties
Normal 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
|
||||||
@@ -51,7 +51,10 @@ void main() async {
|
|||||||
/// The main application module for the Client app.
|
/// The main application module for the Client app.
|
||||||
class AppModule extends Module {
|
class AppModule extends Module {
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[core_localization.LocalizationModule()];
|
List<Module> get imports => <Module>[
|
||||||
|
core_localization.LocalizationModule(),
|
||||||
|
CoreModule(),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
@@ -99,7 +102,9 @@ class AppWidget extends StatelessWidget {
|
|||||||
>(
|
>(
|
||||||
builder:
|
builder:
|
||||||
(BuildContext context, core_localization.LocaleState state) {
|
(BuildContext context, core_localization.LocaleState state) {
|
||||||
return core_localization.TranslationProvider(
|
return KeyedSubtree(
|
||||||
|
key: ValueKey<Locale>(state.locale),
|
||||||
|
child: core_localization.TranslationProvider(
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: "KROW Client",
|
title: "KROW Client",
|
||||||
@@ -114,6 +119,7 @@ class AppWidget extends StatelessWidget {
|
|||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
// Only show if not initial state (avoid showing on cold start)
|
// Only show if not initial state (avoid showing on cold start)
|
||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
|
_showSessionErrorDialog(
|
||||||
|
state.errorMessage ?? 'Session error occurred',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
Modular.to.toClientGetStartedPage();
|
Modular.to.toClientGetStartedPage();
|
||||||
@@ -126,7 +128,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// User can retry by dismissing and continuing
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
},
|
},
|
||||||
child: const Text('Continue'),
|
child: const Text('Continue'),
|
||||||
),
|
),
|
||||||
|
|||||||
3
apps/mobile/apps/staff/android/.gitignore
vendored
3
apps/mobile/apps/staff/android/.gitignore
vendored
@@ -7,8 +7,7 @@ gradle-wrapper.jar
|
|||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore files.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propertiesFile = rootProject.file("key.properties")
|
||||||
|
if (propertiesFile.exists()) {
|
||||||
|
load(propertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.krowwithus.staff"
|
namespace = "com.krowwithus.staff"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -47,11 +55,30 @@ android {
|
|||||||
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (System.getenv()["CI"] == "true") {
|
||||||
|
// CodeMagic CI environment
|
||||||
|
storeFile = file(System.getenv()["CM_KEYSTORE_PATH_STAFF"] ?: "")
|
||||||
|
storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_STAFF"]
|
||||||
|
keyAlias = System.getenv()["CM_KEY_ALIAS_STAFF"]
|
||||||
|
keyPassword = System.getenv()["CM_KEY_PASSWORD_STAFF"]
|
||||||
|
} else {
|
||||||
|
// Local development environment
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,11 @@
|
|||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
{
|
{
|
||||||
"client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com",
|
"client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
"android_info": {
|
"android_info": {
|
||||||
"package_name": "com.krowwithus.client",
|
"package_name": "com.krowwithus.client",
|
||||||
"certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280"
|
"certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,11 +130,11 @@
|
|||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
{
|
{
|
||||||
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
|
"client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
"android_info": {
|
"android_info": {
|
||||||
"package_name": "com.krowwithus.staff",
|
"package_name": "com.krowwithus.staff",
|
||||||
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d"
|
"certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
9
apps/mobile/apps/staff/android/key.properties
Normal file
9
apps/mobile/apps/staff/android/key.properties
Normal 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
|
||||||
@@ -79,7 +79,9 @@ class AppWidget extends StatelessWidget {
|
|||||||
>(
|
>(
|
||||||
builder:
|
builder:
|
||||||
(BuildContext context, core_localization.LocaleState state) {
|
(BuildContext context, core_localization.LocaleState state) {
|
||||||
return core_localization.TranslationProvider(
|
return KeyedSubtree(
|
||||||
|
key: ValueKey<Locale>(state.locale),
|
||||||
|
child: core_localization.TranslationProvider(
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: "KROW Staff",
|
title: "KROW Staff",
|
||||||
theme: UiTheme.light,
|
theme: UiTheme.light,
|
||||||
@@ -93,6 +95,7 @@ class AppWidget extends StatelessWidget {
|
|||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
_sessionExpiredDialogShown = false;
|
_sessionExpiredDialogShown = false;
|
||||||
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||||
|
|
||||||
|
|
||||||
// Navigate to the main app
|
// Navigate to the main app
|
||||||
Modular.to.toStaffHome();
|
Modular.to.toStaffHome();
|
||||||
break;
|
break;
|
||||||
@@ -75,7 +74,9 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
// Only show if not initial state (avoid showing on cold start)
|
// Only show if not initial state (avoid showing on cold start)
|
||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
|
_showSessionErrorDialog(
|
||||||
|
state.errorMessage ?? 'Session error occurred',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
Modular.to.toGetStartedPage();
|
Modular.to.toGetStartedPage();
|
||||||
@@ -127,7 +128,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// User can retry by dismissing and continuing
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
},
|
},
|
||||||
child: const Text('Continue'),
|
child: const Text('Continue'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../navigation_extensions.dart';
|
||||||
import 'route_paths.dart';
|
import 'route_paths.dart';
|
||||||
|
|
||||||
/// Typed navigation extension for the Client application.
|
/// Typed navigation extension for the Client application.
|
||||||
@@ -33,14 +34,14 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
/// This effectively logs out the user by navigating to root.
|
/// This effectively logs out the user by navigating to root.
|
||||||
/// Used when signing out or session expires.
|
/// Used when signing out or session expires.
|
||||||
void toClientRoot() {
|
void toClientRoot() {
|
||||||
navigate(ClientPaths.root);
|
safeNavigate(ClientPaths.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the get started page.
|
/// Navigates to the get started page.
|
||||||
///
|
///
|
||||||
/// This is the landing page for unauthenticated users, offering login/signup options.
|
/// This is the landing page for unauthenticated users, offering login/signup options.
|
||||||
void toClientGetStartedPage() {
|
void toClientGetStartedPage() {
|
||||||
navigate(ClientPaths.getStarted);
|
safeNavigate(ClientPaths.getStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the client sign-in page.
|
/// Navigates to the client sign-in page.
|
||||||
@@ -48,7 +49,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
/// This page allows existing clients to log in using email/password
|
/// This page allows existing clients to log in using email/password
|
||||||
/// or social authentication providers.
|
/// or social authentication providers.
|
||||||
void toClientSignIn() {
|
void toClientSignIn() {
|
||||||
pushNamed(ClientPaths.signIn);
|
safePush(ClientPaths.signIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the client sign-up page.
|
/// Navigates to the client sign-up page.
|
||||||
@@ -56,7 +57,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
/// This page allows new clients to create an account and provides
|
/// This page allows new clients to create an account and provides
|
||||||
/// the initial registration form.
|
/// the initial registration form.
|
||||||
void toClientSignUp() {
|
void toClientSignUp() {
|
||||||
pushNamed(ClientPaths.signUp);
|
safePush(ClientPaths.signUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the client home dashboard.
|
/// Navigates to the client home dashboard.
|
||||||
@@ -66,7 +67,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// Uses pushNamed to avoid trailing slash issues with navigate().
|
/// Uses pushNamed to avoid trailing slash issues with navigate().
|
||||||
void toClientHome() {
|
void toClientHome() {
|
||||||
navigate(ClientPaths.home);
|
safeNavigate(ClientPaths.home);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the client main shell.
|
/// Navigates to the client main shell.
|
||||||
@@ -74,7 +75,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
/// This is the container with bottom navigation. Usually you'd navigate
|
/// This is the container with bottom navigation. Usually you'd navigate
|
||||||
/// to a specific tab instead (like [toClientHome]).
|
/// to a specific tab instead (like [toClientHome]).
|
||||||
void toClientMain() {
|
void toClientMain() {
|
||||||
navigate(ClientPaths.main);
|
safeNavigate(ClientPaths.main);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -85,43 +86,43 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// Displays workforce coverage analytics and metrics.
|
/// Displays workforce coverage analytics and metrics.
|
||||||
void toClientCoverage() {
|
void toClientCoverage() {
|
||||||
navigate(ClientPaths.coverage);
|
safeNavigate(ClientPaths.coverage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Billing tab.
|
/// Navigates to the Billing tab.
|
||||||
///
|
///
|
||||||
/// Access billing history, invoices, and payment methods.
|
/// Access billing history, invoices, and payment methods.
|
||||||
void toClientBilling() {
|
void toClientBilling() {
|
||||||
navigate(ClientPaths.billing);
|
safeNavigate(ClientPaths.billing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Completion Review page.
|
/// Navigates to the Completion Review page.
|
||||||
void toCompletionReview({Object? arguments}) {
|
void toCompletionReview({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.completionReview, arguments: arguments);
|
safePush(ClientPaths.completionReview, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the full list of invoices awaiting approval.
|
/// Navigates to the full list of invoices awaiting approval.
|
||||||
void toAwaitingApproval({Object? arguments}) {
|
Future<Object?> toAwaitingApproval({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.awaitingApproval, arguments: arguments);
|
return safePush(ClientPaths.awaitingApproval, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Invoice Ready page.
|
/// Navigates to the Invoice Ready page.
|
||||||
void toInvoiceReady() {
|
void toInvoiceReady() {
|
||||||
pushNamed(ClientPaths.invoiceReady);
|
safePush(ClientPaths.invoiceReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Orders tab.
|
/// Navigates to the Orders tab.
|
||||||
///
|
///
|
||||||
/// View and manage all shift orders with filtering and sorting.
|
/// View and manage all shift orders with filtering and sorting.
|
||||||
void toClientOrders() {
|
void toClientOrders() {
|
||||||
navigate(ClientPaths.orders);
|
safeNavigate(ClientPaths.orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Reports tab.
|
/// Navigates to the Reports tab.
|
||||||
///
|
///
|
||||||
/// Generate and view workforce reports and analytics.
|
/// Generate and view workforce reports and analytics.
|
||||||
void toClientReports() {
|
void toClientReports() {
|
||||||
navigate(ClientPaths.reports);
|
safeNavigate(ClientPaths.reports);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -132,12 +133,12 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// Manage account settings, notifications, and app preferences.
|
/// Manage account settings, notifications, and app preferences.
|
||||||
void toClientSettings() {
|
void toClientSettings() {
|
||||||
pushNamed(ClientPaths.settings);
|
safePush(ClientPaths.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the edit profile page.
|
/// Pushes the edit profile page.
|
||||||
void toClientEditProfile() {
|
void toClientEditProfile() {
|
||||||
pushNamed('${ClientPaths.settings}/edit-profile');
|
safePush('${ClientPaths.settings}/edit-profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -148,12 +149,12 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// View and manage physical locations/hubs where staff are deployed.
|
/// View and manage physical locations/hubs where staff are deployed.
|
||||||
Future<void> toClientHubs() async {
|
Future<void> toClientHubs() async {
|
||||||
await pushNamed(ClientPaths.hubs);
|
await safePush(ClientPaths.hubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the details of a specific hub.
|
/// Navigates to the details of a specific hub.
|
||||||
Future<bool?> toHubDetails(Hub hub) {
|
Future<bool?> toHubDetails(Hub hub) {
|
||||||
return pushNamed<bool?>(
|
return safePush<bool?>(
|
||||||
ClientPaths.hubDetails,
|
ClientPaths.hubDetails,
|
||||||
arguments: <String, dynamic>{'hub': hub},
|
arguments: <String, dynamic>{'hub': hub},
|
||||||
);
|
);
|
||||||
@@ -161,7 +162,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
|
|
||||||
/// Navigates to the page to add a new hub or edit an existing one.
|
/// Navigates to the page to add a new hub or edit an existing one.
|
||||||
Future<bool?> toEditHub({Hub? hub}) async {
|
Future<bool?> toEditHub({Hub? hub}) async {
|
||||||
return pushNamed<bool?>(
|
return safePush<bool?>(
|
||||||
ClientPaths.editHub,
|
ClientPaths.editHub,
|
||||||
arguments: <String, dynamic>{'hub': hub},
|
arguments: <String, dynamic>{'hub': hub},
|
||||||
// Some versions of Modular allow passing opaque here, but if not
|
// Some versions of Modular allow passing opaque here, but if not
|
||||||
@@ -178,35 +179,35 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// This is the starting point for all order creation flows.
|
/// This is the starting point for all order creation flows.
|
||||||
void toCreateOrder({Object? arguments}) {
|
void toCreateOrder({Object? arguments}) {
|
||||||
navigate(ClientPaths.createOrder, arguments: arguments);
|
safeNavigate(ClientPaths.createOrder, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the rapid order creation flow.
|
/// Pushes the rapid order creation flow.
|
||||||
///
|
///
|
||||||
/// Quick shift creation with simplified inputs for urgent needs.
|
/// Quick shift creation with simplified inputs for urgent needs.
|
||||||
void toCreateOrderRapid({Object? arguments}) {
|
void toCreateOrderRapid({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.createOrderRapid, arguments: arguments);
|
safePush(ClientPaths.createOrderRapid, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the one-time order creation flow.
|
/// Pushes the one-time order creation flow.
|
||||||
///
|
///
|
||||||
/// Create a shift that occurs once at a specific date and time.
|
/// Create a shift that occurs once at a specific date and time.
|
||||||
void toCreateOrderOneTime({Object? arguments}) {
|
void toCreateOrderOneTime({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.createOrderOneTime, arguments: arguments);
|
safePush(ClientPaths.createOrderOneTime, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the recurring order creation flow.
|
/// Pushes the recurring order creation flow.
|
||||||
///
|
///
|
||||||
/// Create shifts that repeat on a defined schedule (daily, weekly, etc.).
|
/// Create shifts that repeat on a defined schedule (daily, weekly, etc.).
|
||||||
void toCreateOrderRecurring({Object? arguments}) {
|
void toCreateOrderRecurring({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.createOrderRecurring, arguments: arguments);
|
safePush(ClientPaths.createOrderRecurring, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the permanent order creation flow.
|
/// Pushes the permanent order creation flow.
|
||||||
///
|
///
|
||||||
/// Create a long-term or permanent staffing position.
|
/// Create a long-term or permanent staffing position.
|
||||||
void toCreateOrderPermanent({Object? arguments}) {
|
void toCreateOrderPermanent({Object? arguments}) {
|
||||||
pushNamed(ClientPaths.createOrderPermanent, arguments: arguments);
|
safePush(ClientPaths.createOrderPermanent, arguments: arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -215,7 +216,7 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
|
|
||||||
/// Navigates to the order details page to a specific date.
|
/// Navigates to the order details page to a specific date.
|
||||||
void toOrdersSpecificDate(DateTime date) {
|
void toOrdersSpecificDate(DateTime date) {
|
||||||
navigate(
|
safeNavigate(
|
||||||
ClientPaths.orders,
|
ClientPaths.orders,
|
||||||
arguments: <String, DateTime>{'initialDate': date},
|
arguments: <String, DateTime>{'initialDate': date},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'client/route_paths.dart';
|
||||||
|
import 'staff/route_paths.dart';
|
||||||
|
|
||||||
/// Base navigation utilities extension for [IModularNavigator].
|
/// Base navigation utilities extension for [IModularNavigator].
|
||||||
///
|
///
|
||||||
@@ -21,17 +24,15 @@ extension NavigationExtensions on IModularNavigator {
|
|||||||
/// * [arguments] - Optional arguments to pass to the route
|
/// * [arguments] - Optional arguments to pass to the route
|
||||||
///
|
///
|
||||||
/// Returns `true` if navigation was successful, `false` otherwise.
|
/// Returns `true` if navigation was successful, `false` otherwise.
|
||||||
Future<bool> safeNavigate(
|
Future<bool> safeNavigate(String path, {Object? arguments}) async {
|
||||||
String path, {
|
|
||||||
Object? arguments,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
navigate(path, arguments: arguments);
|
navigate(path, arguments: arguments);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// In production, you might want to log this to a monitoring service
|
// In production, you might want to log this to a monitoring service
|
||||||
// ignore: avoid_print
|
// ignore: avoid_debugPrint
|
||||||
print('Navigation error to $path: $e');
|
debugPrint('Navigation error to $path: $e');
|
||||||
|
navigateToHome();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,8 +55,30 @@ extension NavigationExtensions on IModularNavigator {
|
|||||||
return await pushNamed<T>(routeName, arguments: arguments);
|
return await pushNamed<T>(routeName, arguments: arguments);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// In production, you might want to log this to a monitoring service
|
// In production, you might want to log this to a monitoring service
|
||||||
// ignore: avoid_print
|
// ignore: avoid_debugPrint
|
||||||
print('Push navigation error to $routeName: $e');
|
debugPrint('Push navigation error to $routeName: $e');
|
||||||
|
navigateToHome();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safely pushes a named route and removes until a predicate is met.
|
||||||
|
Future<T?> safePushNamedAndRemoveUntil<T extends Object?>(
|
||||||
|
String routeName,
|
||||||
|
bool Function(Route<dynamic>) predicate, {
|
||||||
|
Object? arguments,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await pushNamedAndRemoveUntil<T>(
|
||||||
|
routeName,
|
||||||
|
predicate,
|
||||||
|
arguments: arguments,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// In production, you might want to log this to a monitoring service
|
||||||
|
// ignore: avoid_debugPrint
|
||||||
|
debugPrint('PushNamedAndRemoveUntil error to $routeName: $e');
|
||||||
|
navigateToHome();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,14 +91,31 @@ extension NavigationExtensions on IModularNavigator {
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pops the current route if possible.
|
/// Pops the current route if possible, otherwise navigates to home.
|
||||||
///
|
///
|
||||||
/// Returns `true` if a route was popped, `false` if already at root.
|
/// Returns `true` if a route was popped, `false` if it navigated to home.
|
||||||
bool popSafe() {
|
bool popSafe<T extends Object?>([T? result]) {
|
||||||
if (canPop()) {
|
if (canPop()) {
|
||||||
pop();
|
pop(result);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
navigateToHome();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the designated home page based on the current context.
|
||||||
|
///
|
||||||
|
/// Checks the current path to determine if the user is in the Client
|
||||||
|
/// or Staff portion of the application and routes to their respective home.
|
||||||
|
void navigateToHome() {
|
||||||
|
final String currentPath = Modular.to.path;
|
||||||
|
if (currentPath.contains('/client')) {
|
||||||
|
navigate(ClientPaths.home);
|
||||||
|
} else if (currentPath.contains('/worker') ||
|
||||||
|
currentPath.contains('/staff')) {
|
||||||
|
navigate(StaffPaths.home);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../navigation_extensions.dart';
|
||||||
import 'route_paths.dart';
|
import 'route_paths.dart';
|
||||||
|
|
||||||
/// Typed navigation extension for the Staff application.
|
/// Typed navigation extension for the Staff application.
|
||||||
@@ -33,76 +34,36 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
/// This effectively logs out the user by navigating to root.
|
/// This effectively logs out the user by navigating to root.
|
||||||
/// Used when signing out or session expires.
|
/// Used when signing out or session expires.
|
||||||
void toInitialPage() {
|
void toInitialPage() {
|
||||||
navigate(StaffPaths.root);
|
safeNavigate(StaffPaths.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the get started page.
|
|
||||||
///
|
|
||||||
/// This is the landing page for unauthenticated users, offering login/signup options.
|
|
||||||
void toGetStartedPage() {
|
void toGetStartedPage() {
|
||||||
navigate(StaffPaths.getStarted);
|
safeNavigate(StaffPaths.getStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the phone verification page.
|
|
||||||
///
|
|
||||||
/// Used for both login and signup flows to verify phone numbers via OTP.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// * [mode] - The authentication mode: 'login' or 'signup'
|
|
||||||
///
|
|
||||||
/// The mode is passed as an argument and used by the verification page
|
|
||||||
/// to determine the appropriate flow.
|
|
||||||
void toPhoneVerification(String mode) {
|
void toPhoneVerification(String mode) {
|
||||||
pushNamed(
|
safePush(
|
||||||
StaffPaths.phoneVerification,
|
StaffPaths.phoneVerification,
|
||||||
arguments: <String, String>{'mode': mode},
|
arguments: <String, String>{'mode': mode},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the profile setup page, replacing the current route.
|
|
||||||
///
|
|
||||||
/// This is typically called after successful phone verification for new
|
|
||||||
/// staff members. Uses pushReplacement to prevent going back to verification.
|
|
||||||
void toProfileSetup() {
|
void toProfileSetup() {
|
||||||
pushNamed(StaffPaths.profileSetup);
|
safePush(StaffPaths.profileSetup);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// MAIN NAVIGATION
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Navigates to the staff home dashboard.
|
|
||||||
///
|
|
||||||
/// This is the main landing page for authenticated staff members.
|
|
||||||
/// Displays shift cards, quick actions, and notifications.
|
|
||||||
void toStaffHome() {
|
void toStaffHome() {
|
||||||
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
|
safePushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the benefits overview page.
|
|
||||||
void toBenefits() {
|
void toBenefits() {
|
||||||
pushNamed(StaffPaths.benefits);
|
safePush(StaffPaths.benefits);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the staff main shell.
|
|
||||||
///
|
|
||||||
/// This is the container with bottom navigation. Navigates to home tab
|
|
||||||
/// by default. Usually you'd navigate to a specific tab instead.
|
|
||||||
void toStaffMain() {
|
void toStaffMain() {
|
||||||
pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// MAIN NAVIGATION TABS
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Navigates to the Shifts tab.
|
|
||||||
///
|
|
||||||
/// Browse available shifts, accepted shifts, and shift history.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// * [selectedDate] - Optional date to pre-select in the shifts view
|
|
||||||
/// * [initialTab] - Optional initial tab (via query parameter)
|
|
||||||
void toShifts({
|
void toShifts({
|
||||||
DateTime? selectedDate,
|
DateTime? selectedDate,
|
||||||
String? initialTab,
|
String? initialTab,
|
||||||
@@ -118,94 +79,47 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
if (refreshAvailable == true) {
|
if (refreshAvailable == true) {
|
||||||
args['refreshAvailable'] = true;
|
args['refreshAvailable'] = true;
|
||||||
}
|
}
|
||||||
navigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args);
|
safeNavigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Payments tab.
|
|
||||||
///
|
|
||||||
/// View payment history, earnings breakdown, and tax information.
|
|
||||||
void toPayments() {
|
void toPayments() {
|
||||||
pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false);
|
safePushNamedAndRemoveUntil(StaffPaths.payments, (_) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Clock In tab.
|
|
||||||
///
|
|
||||||
/// Access time tracking interface for active shifts.
|
|
||||||
void toClockIn() {
|
void toClockIn() {
|
||||||
pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false);
|
safePushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Profile tab.
|
|
||||||
///
|
|
||||||
/// Manage personal information, documents, and preferences.
|
|
||||||
void toProfile() {
|
void toProfile() {
|
||||||
navigate(StaffPaths.profile);
|
safeNavigate(StaffPaths.profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// SHIFT MANAGEMENT
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Navigates to the shift details page for a specific shift.
|
|
||||||
///
|
|
||||||
/// Displays comprehensive information about a shift including location,
|
|
||||||
/// time, pay rate, and action buttons for accepting/declining/applying.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// * [shift] - The shift entity to display details for
|
|
||||||
///
|
|
||||||
/// The shift object is passed as an argument and can be retrieved
|
|
||||||
/// in the details page.
|
|
||||||
void toShiftDetails(Shift shift) {
|
void toShiftDetails(Shift shift) {
|
||||||
navigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// ONBOARDING & PROFILE SECTIONS
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the personal information page.
|
|
||||||
///
|
|
||||||
/// Collect or edit basic personal information.
|
|
||||||
void toPersonalInfo() {
|
void toPersonalInfo() {
|
||||||
pushNamed(StaffPaths.onboardingPersonalInfo);
|
safePush(StaffPaths.onboardingPersonalInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the preferred locations editing page.
|
|
||||||
///
|
|
||||||
/// Allows staff to search and manage their preferred US work locations.
|
|
||||||
void toPreferredLocations() {
|
void toPreferredLocations() {
|
||||||
pushNamed(StaffPaths.preferredLocations);
|
safePush(StaffPaths.preferredLocations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the emergency contact page.
|
|
||||||
///
|
|
||||||
/// Manage emergency contact details for safety purposes.
|
|
||||||
void toEmergencyContact() {
|
void toEmergencyContact() {
|
||||||
pushNamed(StaffPaths.emergencyContact);
|
safePush(StaffPaths.emergencyContact);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the work experience page.
|
|
||||||
///
|
|
||||||
/// Record previous work experience and qualifications.
|
|
||||||
void toExperience() {
|
void toExperience() {
|
||||||
navigate(StaffPaths.experience);
|
safeNavigate(StaffPaths.experience);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the attire preferences page.
|
|
||||||
///
|
|
||||||
/// Record sizing and appearance information for uniform allocation.
|
|
||||||
void toAttire() {
|
void toAttire() {
|
||||||
navigate(StaffPaths.attire);
|
safeNavigate(StaffPaths.attire);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the attire capture page.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// * [item] - The attire item to capture
|
|
||||||
/// * [initialPhotoUrl] - Optional initial photo URL
|
|
||||||
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
|
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
|
||||||
navigate(
|
safeNavigate(
|
||||||
StaffPaths.attireCapture,
|
StaffPaths.attireCapture,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'item': item,
|
'item': item,
|
||||||
@@ -214,24 +128,12 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// COMPLIANCE & DOCUMENTS
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the documents management page.
|
|
||||||
///
|
|
||||||
/// Upload and manage required documents like ID and work permits.
|
|
||||||
void toDocuments() {
|
void toDocuments() {
|
||||||
navigate(StaffPaths.documents);
|
safeNavigate(StaffPaths.documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the document upload page.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// * [document] - The document metadata to upload
|
|
||||||
/// * [initialUrl] - Optional initial document URL
|
|
||||||
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
|
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
|
||||||
navigate(
|
safeNavigate(
|
||||||
StaffPaths.documentUpload,
|
StaffPaths.documentUpload,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'document': document,
|
'document': document,
|
||||||
@@ -240,124 +142,71 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the certificates management page.
|
|
||||||
///
|
|
||||||
/// Manage professional certificates (e.g., food handling, CPR).
|
|
||||||
void toCertificates() {
|
void toCertificates() {
|
||||||
pushNamed(StaffPaths.certificates);
|
safePush(StaffPaths.certificates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// FINANCIAL INFORMATION
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the bank account information page.
|
|
||||||
///
|
|
||||||
/// Manage banking details for direct deposit payments.
|
|
||||||
void toBankAccount() {
|
void toBankAccount() {
|
||||||
pushNamed(StaffPaths.bankAccount);
|
safePush(StaffPaths.bankAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the tax forms page.
|
|
||||||
///
|
|
||||||
/// Manage W-4, tax withholding, and related tax documents.
|
|
||||||
void toTaxForms() {
|
void toTaxForms() {
|
||||||
pushNamed(StaffPaths.taxForms);
|
safePush(StaffPaths.taxForms);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toLanguageSelection() {
|
||||||
|
safePush(StaffPaths.languageSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toFormI9() {
|
||||||
|
safeNavigate(StaffPaths.formI9);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toFormW4() {
|
||||||
|
safeNavigate(StaffPaths.formW4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the time card page.
|
|
||||||
///
|
|
||||||
/// View detailed time entries and timesheets.
|
|
||||||
void toTimeCard() {
|
void toTimeCard() {
|
||||||
pushNamed(StaffPaths.timeCard);
|
safePush(StaffPaths.timeCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// SCHEDULING & AVAILABILITY
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the availability management page.
|
|
||||||
///
|
|
||||||
/// Define when the staff member is available to work.
|
|
||||||
void toAvailability() {
|
void toAvailability() {
|
||||||
pushNamed(StaffPaths.availability);
|
safePush(StaffPaths.availability);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// ADDITIONAL FEATURES
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the KROW University page (placeholder).
|
|
||||||
///
|
|
||||||
/// Access training materials and educational courses.
|
|
||||||
void toKrowUniversity() {
|
void toKrowUniversity() {
|
||||||
pushNamed(StaffPaths.krowUniversity);
|
safePush(StaffPaths.krowUniversity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the trainings page (placeholder).
|
|
||||||
///
|
|
||||||
/// View and complete required training modules.
|
|
||||||
void toTrainings() {
|
void toTrainings() {
|
||||||
pushNamed(StaffPaths.trainings);
|
safePush(StaffPaths.trainings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the leaderboard page (placeholder).
|
|
||||||
///
|
|
||||||
/// View performance rankings and achievements.
|
|
||||||
void toLeaderboard() {
|
void toLeaderboard() {
|
||||||
pushNamed(StaffPaths.leaderboard);
|
safePush(StaffPaths.leaderboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the FAQs page.
|
|
||||||
///
|
|
||||||
/// Access frequently asked questions and help resources.
|
|
||||||
void toFaqs() {
|
void toFaqs() {
|
||||||
pushNamed(StaffPaths.faqs);
|
safePush(StaffPaths.faqs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// PRIVACY & SECURITY
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Navigates to the privacy and security settings page.
|
|
||||||
///
|
|
||||||
/// Manage privacy preferences including:
|
|
||||||
/// * Location sharing settings
|
|
||||||
/// * View terms of service
|
|
||||||
/// * View privacy policy
|
|
||||||
void toPrivacySecurity() {
|
void toPrivacySecurity() {
|
||||||
pushNamed(StaffPaths.privacySecurity);
|
safePush(StaffPaths.privacySecurity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Terms of Service page.
|
|
||||||
///
|
|
||||||
/// Display the full terms of service document in a dedicated page view.
|
|
||||||
void toTermsOfService() {
|
void toTermsOfService() {
|
||||||
pushNamed(StaffPaths.termsOfService);
|
safePush(StaffPaths.termsOfService);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the Privacy Policy page.
|
|
||||||
///
|
|
||||||
/// Display the full privacy policy document in a dedicated page view.
|
|
||||||
void toPrivacyPolicy() {
|
void toPrivacyPolicy() {
|
||||||
pushNamed(StaffPaths.privacyPolicy);
|
safePush(StaffPaths.privacyPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// MESSAGING & COMMUNICATION
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
/// Pushes the messages page (placeholder).
|
|
||||||
///
|
|
||||||
/// Access internal messaging system.
|
|
||||||
void toMessages() {
|
void toMessages() {
|
||||||
pushNamed(StaffPaths.messages);
|
safePush(StaffPaths.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the settings page (placeholder).
|
|
||||||
///
|
|
||||||
/// General app settings and preferences.
|
|
||||||
void toSettings() {
|
void toSettings() {
|
||||||
pushNamed(StaffPaths.settings);
|
safePush(StaffPaths.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class LocaleBloc extends Bloc<LocaleEvent, LocaleState> {
|
|||||||
supportedLocales: state.supportedLocales,
|
supportedLocales: state.supportedLocales,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. Reload from persistent storage to ensure synchronization
|
||||||
|
add(const LoadLocale());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the [LoadLocale] event by retrieving it via the use case and updating settings.
|
/// Handles the [LoadLocale] event by retrieving it via the use case and updating settings.
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Locale> getSavedLocale() async {
|
Future<Locale> getSavedLocale() async {
|
||||||
|
final String? savedLanguageCode = await localDataSource.getLanguageCode();
|
||||||
|
if (savedLanguageCode != null) {
|
||||||
|
final Locale savedLocale = Locale(savedLanguageCode);
|
||||||
|
if (getSupportedLocales().contains(savedLocale)) {
|
||||||
|
return savedLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
return getDefaultLocale();
|
return getDefaultLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
"listening": "Listening...",
|
"listening": "Listening...",
|
||||||
"send": "Send Message",
|
"send": "Send Message",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
|
"transcribing": "Transcribing...",
|
||||||
"success_title": "Request Sent!",
|
"success_title": "Request Sent!",
|
||||||
"success_message": "We're finding available workers for you right now. You'll be notified as they accept.",
|
"success_message": "We're finding available workers for you right now. You'll be notified as they accept.",
|
||||||
"back_to_orders": "Back to Orders"
|
"back_to_orders": "Back to Orders"
|
||||||
@@ -540,8 +541,8 @@
|
|||||||
"min_break": "min break"
|
"min_break": "min break"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve_pay": "Approve & Process Payment",
|
"approve_pay": "Approve",
|
||||||
"flag_review": "Flag for Review",
|
"flag_review": "Review",
|
||||||
"download_pdf": "Download Invoice PDF"
|
"download_pdf": "Download Invoice PDF"
|
||||||
},
|
},
|
||||||
"flag_dialog": {
|
"flag_dialog": {
|
||||||
@@ -1317,7 +1318,7 @@
|
|||||||
},
|
},
|
||||||
"find_shifts": {
|
"find_shifts": {
|
||||||
"incomplete_profile_banner_title": "Your account isn't complete yet.",
|
"incomplete_profile_banner_title": "Your account isn't complete yet.",
|
||||||
"incomplete_profile_banner_message": "You won't be able to apply for shifts until your account is fully set up. Complete your account now to unlock shift applications and start getting matched with opportunities.",
|
"incomplete_profile_banner_message": "Complete your account now to unlock shift applications and start getting matched with opportunities.",
|
||||||
"incomplete_profile_cta": "Complete your account now",
|
"incomplete_profile_cta": "Complete your account now",
|
||||||
"search_hint": "Search jobs, location...",
|
"search_hint": "Search jobs, location...",
|
||||||
"filter_all": "All Jobs",
|
"filter_all": "All Jobs",
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
"listening": "Escuchando...",
|
"listening": "Escuchando...",
|
||||||
"send": "Enviar Mensaje",
|
"send": "Enviar Mensaje",
|
||||||
"sending": "Enviando...",
|
"sending": "Enviando...",
|
||||||
|
"transcribing": "Transcribiendo...",
|
||||||
"success_title": "\u00a1Solicitud Enviada!",
|
"success_title": "\u00a1Solicitud Enviada!",
|
||||||
"success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.",
|
"success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.",
|
||||||
"back_to_orders": "Volver a \u00d3rdenes"
|
"back_to_orders": "Volver a \u00d3rdenes"
|
||||||
@@ -535,8 +536,8 @@
|
|||||||
"min_break": "min de descanso"
|
"min_break": "min de descanso"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve_pay": "Aprobar y Procesar Pago",
|
"approve_pay": "Aprobar",
|
||||||
"flag_review": "Marcar para Revisi\u00f3n",
|
"flag_review": "Revisi\u00f3n",
|
||||||
"download_pdf": "Descargar PDF de Factura"
|
"download_pdf": "Descargar PDF de Factura"
|
||||||
},
|
},
|
||||||
"flag_dialog": {
|
"flag_dialog": {
|
||||||
@@ -1312,7 +1313,7 @@
|
|||||||
},
|
},
|
||||||
"find_shifts": {
|
"find_shifts": {
|
||||||
"incomplete_profile_banner_title": "Tu cuenta aún no está completa.",
|
"incomplete_profile_banner_title": "Tu cuenta aún no está completa.",
|
||||||
"incomplete_profile_banner_message": "No podrás solicitar turnos hasta que tu cuenta esté completamente configurada. Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.",
|
"incomplete_profile_banner_message": "Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.",
|
||||||
"incomplete_profile_cta": "Completa tu cuenta ahora",
|
"incomplete_profile_cta": "Completa tu cuenta ahora",
|
||||||
"search_hint": "Buscar trabajos, ubicaci\u00f3n...",
|
"search_hint": "Buscar trabajos, ubicaci\u00f3n...",
|
||||||
"filter_all": "Todos",
|
"filter_all": "Todos",
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ import '../../domain/repositories/billing_connector_repository.dart';
|
|||||||
|
|
||||||
/// Implementation of [BillingConnectorRepository].
|
/// Implementation of [BillingConnectorRepository].
|
||||||
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
||||||
BillingConnectorRepositoryImpl({
|
BillingConnectorRepositoryImpl({dc.DataConnectService? service})
|
||||||
dc.DataConnectService? service,
|
: _service = service ?? dc.DataConnectService.instance;
|
||||||
}) : _service = service ?? dc.DataConnectService.instance;
|
|
||||||
|
|
||||||
final dc.DataConnectService _service;
|
final dc.DataConnectService _service;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId}) async {
|
Future<List<BusinessBankAccount>> getBankAccounts({
|
||||||
|
required String businessId,
|
||||||
|
}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
final QueryResult<dc.GetAccountsByOwnerIdData, dc.GetAccountsByOwnerIdVariables> result = await _service.connector
|
final QueryResult<
|
||||||
|
dc.GetAccountsByOwnerIdData,
|
||||||
|
dc.GetAccountsByOwnerIdVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.getAccountsByOwnerId(ownerId: businessId)
|
.getAccountsByOwnerId(ownerId: businessId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
@@ -26,21 +31,32 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
@override
|
@override
|
||||||
Future<double> getCurrentBillAmount({required String businessId}) async {
|
Future<double> getCurrentBillAmount({required String businessId}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
|
final QueryResult<
|
||||||
|
dc.ListInvoicesByBusinessIdData,
|
||||||
|
dc.ListInvoicesByBusinessIdVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices
|
return result.data.invoices
|
||||||
.map(_mapInvoice)
|
.map(_mapInvoice)
|
||||||
.where((Invoice i) => i.status == InvoiceStatus.open)
|
.where((Invoice i) => i.status == InvoiceStatus.open)
|
||||||
.fold<double>(0.0, (double sum, Invoice item) => sum + item.totalAmount);
|
.fold<double>(
|
||||||
|
0.0,
|
||||||
|
(double sum, Invoice item) => sum + item.totalAmount,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
|
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
|
final QueryResult<
|
||||||
|
dc.ListInvoicesByBusinessIdData,
|
||||||
|
dc.ListInvoicesByBusinessIdVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
.limit(20)
|
.limit(20)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -55,14 +71,22 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
|
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
|
final QueryResult<
|
||||||
|
dc.ListInvoicesByBusinessIdData,
|
||||||
|
dc.ListInvoicesByBusinessIdVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices
|
return result.data.invoices
|
||||||
.map(_mapInvoice)
|
.map(_mapInvoice)
|
||||||
.where((Invoice i) =>
|
.where(
|
||||||
i.status != InvoiceStatus.paid)
|
(Invoice i) =>
|
||||||
|
i.status != InvoiceStatus.paid &&
|
||||||
|
i.status != InvoiceStatus.disputed &&
|
||||||
|
i.status != InvoiceStatus.open,
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,16 +103,25 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
|
|
||||||
if (period == BillingPeriod.week) {
|
if (period == BillingPeriod.week) {
|
||||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||||
final DateTime monday = DateTime(now.year, now.month, now.day)
|
final DateTime monday = DateTime(
|
||||||
.subtract(Duration(days: daysFromMonday));
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
).subtract(Duration(days: daysFromMonday));
|
||||||
start = monday;
|
start = monday;
|
||||||
end = monday.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
|
end = monday.add(
|
||||||
|
const Duration(days: 6, hours: 23, minutes: 59, seconds: 59),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
start = DateTime(now.year, now.month, 1);
|
start = DateTime(now.year, now.month, 1);
|
||||||
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
|
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
|
||||||
}
|
}
|
||||||
|
|
||||||
final QueryResult<dc.ListShiftRolesByBusinessAndDatesSummaryData, dc.ListShiftRolesByBusinessAndDatesSummaryVariables> result = await _service.connector
|
final QueryResult<
|
||||||
|
dc.ListShiftRolesByBusinessAndDatesSummaryData,
|
||||||
|
dc.ListShiftRolesByBusinessAndDatesSummaryVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.listShiftRolesByBusinessAndDatesSummary(
|
.listShiftRolesByBusinessAndDatesSummary(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
start: _service.toTimestamp(start),
|
start: _service.toTimestamp(start),
|
||||||
@@ -96,11 +129,13 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles> shiftRoles = result.data.shiftRoles;
|
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
|
||||||
|
shiftRoles = result.data.shiftRoles;
|
||||||
if (shiftRoles.isEmpty) return <InvoiceItem>[];
|
if (shiftRoles.isEmpty) return <InvoiceItem>[];
|
||||||
|
|
||||||
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
||||||
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role in shiftRoles) {
|
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
|
||||||
|
in shiftRoles) {
|
||||||
final String roleId = role.roleId;
|
final String roleId = role.roleId;
|
||||||
final String roleName = role.role.name;
|
final String roleName = role.role.name;
|
||||||
final double hours = role.hours ?? 0.0;
|
final double hours = role.hours ?? 0.0;
|
||||||
@@ -123,14 +158,16 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return summary.values
|
return summary.values
|
||||||
.map((_RoleSummary item) => InvoiceItem(
|
.map(
|
||||||
|
(_RoleSummary item) => InvoiceItem(
|
||||||
id: item.roleId,
|
id: item.roleId,
|
||||||
invoiceId: item.roleId,
|
invoiceId: item.roleId,
|
||||||
staffId: item.roleName,
|
staffId: item.roleName,
|
||||||
workHours: item.totalHours,
|
workHours: item.totalHours,
|
||||||
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
||||||
amount: item.totalValue,
|
amount: item.totalValue,
|
||||||
))
|
),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -146,7 +183,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disputeInvoice({required String id, required String reason}) async {
|
Future<void> disputeInvoice({
|
||||||
|
required String id,
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
await _service.connector
|
await _service.connector
|
||||||
.updateInvoice(id: id)
|
.updateInvoice(id: id)
|
||||||
@@ -159,23 +199,39 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
// --- MAPPERS ---
|
// --- MAPPERS ---
|
||||||
|
|
||||||
Invoice _mapInvoice(dynamic invoice) {
|
Invoice _mapInvoice(dynamic invoice) {
|
||||||
final List<dynamic> rolesData = invoice.roles is List ? invoice.roles : [];
|
List<InvoiceWorker> workers = <InvoiceWorker>[];
|
||||||
final List<InvoiceWorker> workers = rolesData.map((dynamic r) {
|
|
||||||
|
// Try to get workers from denormalized 'roles' field first
|
||||||
|
final List<dynamic> rolesData = invoice.roles is List
|
||||||
|
? invoice.roles
|
||||||
|
: <dynamic>[];
|
||||||
|
if (rolesData.isNotEmpty) {
|
||||||
|
workers = rolesData.map((dynamic r) {
|
||||||
final Map<String, dynamic> role = r as Map<String, dynamic>;
|
final Map<String, dynamic> role = r as Map<String, dynamic>;
|
||||||
|
|
||||||
// Handle various possible key naming conventions in the JSON data
|
// Handle various possible key naming conventions in the JSON data
|
||||||
final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
|
final String name =
|
||||||
final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
|
role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
|
||||||
final double amount = (role['amount'] as num?)?.toDouble() ??
|
final String roleTitle =
|
||||||
(role['totalValue'] as num?)?.toDouble() ?? 0.0;
|
role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
|
||||||
final double hours = (role['hours'] as num?)?.toDouble() ??
|
final double amount =
|
||||||
|
(role['amount'] as num?)?.toDouble() ??
|
||||||
|
(role['totalValue'] as num?)?.toDouble() ??
|
||||||
|
0.0;
|
||||||
|
final double hours =
|
||||||
|
(role['hours'] as num?)?.toDouble() ??
|
||||||
(role['workHours'] as num?)?.toDouble() ??
|
(role['workHours'] as num?)?.toDouble() ??
|
||||||
(role['totalHours'] as num?)?.toDouble() ?? 0.0;
|
(role['totalHours'] as num?)?.toDouble() ??
|
||||||
final double rate = (role['rate'] as num?)?.toDouble() ??
|
0.0;
|
||||||
(role['hourlyRate'] as num?)?.toDouble() ?? 0.0;
|
final double rate =
|
||||||
|
(role['rate'] as num?)?.toDouble() ??
|
||||||
|
(role['hourlyRate'] as num?)?.toDouble() ??
|
||||||
|
0.0;
|
||||||
|
|
||||||
final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
|
final dynamic checkInVal =
|
||||||
final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
|
role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
|
||||||
|
final dynamic checkOutVal =
|
||||||
|
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
|
||||||
|
|
||||||
return InvoiceWorker(
|
return InvoiceWorker(
|
||||||
name: name,
|
name: name,
|
||||||
@@ -186,9 +242,57 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
checkIn: _service.toDateTime(checkInVal),
|
checkIn: _service.toDateTime(checkInVal),
|
||||||
checkOut: _service.toDateTime(checkOutVal),
|
checkOut: _service.toDateTime(checkOutVal),
|
||||||
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
|
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
|
||||||
avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
|
avatarUrl:
|
||||||
|
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
}
|
||||||
|
// Fallback: If roles is empty, try to get workers from shift applications
|
||||||
|
else if (invoice.shift != null &&
|
||||||
|
invoice.shift.applications_on_shift != null) {
|
||||||
|
final List<dynamic> apps = invoice.shift.applications_on_shift;
|
||||||
|
workers = apps.map((dynamic app) {
|
||||||
|
final String name = app.staff?.fullName ?? 'Unknown';
|
||||||
|
final String roleTitle = app.shiftRole?.role?.name ?? 'Staff';
|
||||||
|
final double amount =
|
||||||
|
(app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0;
|
||||||
|
final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
|
// Calculate rate if not explicitly provided
|
||||||
|
double rate = 0.0;
|
||||||
|
if (hours > 0) {
|
||||||
|
rate = amount / hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map break type to minutes
|
||||||
|
int breakMin = 0;
|
||||||
|
final String? breakType = app.shiftRole?.breakType?.toString();
|
||||||
|
if (breakType != null) {
|
||||||
|
if (breakType.contains('10'))
|
||||||
|
breakMin = 10;
|
||||||
|
else if (breakType.contains('15'))
|
||||||
|
breakMin = 15;
|
||||||
|
else if (breakType.contains('30'))
|
||||||
|
breakMin = 30;
|
||||||
|
else if (breakType.contains('45'))
|
||||||
|
breakMin = 45;
|
||||||
|
else if (breakType.contains('60'))
|
||||||
|
breakMin = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvoiceWorker(
|
||||||
|
name: name,
|
||||||
|
role: roleTitle,
|
||||||
|
amount: amount,
|
||||||
|
hours: hours,
|
||||||
|
rate: rate,
|
||||||
|
checkIn: _service.toDateTime(app.checkInTime),
|
||||||
|
checkOut: _service.toDateTime(app.checkOutTime),
|
||||||
|
breakMinutes: breakMin,
|
||||||
|
avatarUrl: app.staff?.photoUrl,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
return Invoice(
|
return Invoice(
|
||||||
id: invoice.id,
|
id: invoice.id,
|
||||||
@@ -202,8 +306,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
issueDate: _service.toDateTime(invoice.issueDate)!,
|
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||||
title: invoice.order?.eventName,
|
title: invoice.order?.eventName,
|
||||||
clientName: invoice.business?.businessName,
|
clientName: invoice.business?.businessName,
|
||||||
locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
|
locationAddress:
|
||||||
staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
|
invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
|
||||||
|
staffCount:
|
||||||
|
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
|
||||||
totalHours: _calculateTotalHours(rolesData),
|
totalHours: _calculateTotalHours(rolesData),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
);
|
);
|
||||||
@@ -256,10 +362,7 @@ class _RoleSummary {
|
|||||||
final double totalHours;
|
final double totalHours;
|
||||||
final double totalValue;
|
final double totalValue;
|
||||||
|
|
||||||
_RoleSummary copyWith({
|
_RoleSummary copyWith({double? totalHours, double? totalValue}) {
|
||||||
double? totalHours,
|
|
||||||
double? totalValue,
|
|
||||||
}) {
|
|
||||||
return _RoleSummary(
|
return _RoleSummary(
|
||||||
roleId: roleId,
|
roleId: roleId,
|
||||||
roleName: roleName,
|
roleName: roleName,
|
||||||
@@ -268,4 +371,3 @@ class _RoleSummary {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,13 +205,23 @@ mixin SessionHandlerMixin {
|
|||||||
try {
|
try {
|
||||||
_emitSessionState(SessionState.loading());
|
_emitSessionState(SessionState.loading());
|
||||||
|
|
||||||
// Validate role if allowed roles are specified
|
// Validate role only when allowed roles are specified.
|
||||||
if (_allowedRoles.isNotEmpty) {
|
if (_allowedRoles.isNotEmpty) {
|
||||||
final bool isAuthorized = await validateUserRole(
|
final String? userRole = await fetchUserRole(user.uid);
|
||||||
user.uid,
|
|
||||||
_allowedRoles,
|
if (userRole == null) {
|
||||||
);
|
// User has no record in the database yet. This is expected during
|
||||||
if (!isAuthorized) {
|
// the sign-up flow: Firebase Auth fires authStateChanges before the
|
||||||
|
// repository has created the PostgreSQL user record. Do NOT sign out —
|
||||||
|
// just emit unauthenticated and let the registration flow complete.
|
||||||
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_allowedRoles.contains(userRole)) {
|
||||||
|
// User IS in the database but has a role that is not permitted in
|
||||||
|
// this app (e.g., a STAFF-only user trying to use the Client app).
|
||||||
|
// Sign them out to force them to use the correct app.
|
||||||
await auth.signOut();
|
await auth.signOut();
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ export 'src/widgets/ui_button.dart';
|
|||||||
export 'src/widgets/ui_chip.dart';
|
export 'src/widgets/ui_chip.dart';
|
||||||
export 'src/widgets/ui_loading_page.dart';
|
export 'src/widgets/ui_loading_page.dart';
|
||||||
export 'src/widgets/ui_snackbar.dart';
|
export 'src/widgets/ui_snackbar.dart';
|
||||||
|
export 'src/widgets/ui_notice_banner.dart';
|
||||||
|
export 'src/widgets/ui_empty_state.dart';
|
||||||
|
|||||||
@@ -288,4 +288,7 @@ class UiIcons {
|
|||||||
|
|
||||||
/// Microphone icon
|
/// Microphone icon
|
||||||
static const IconData microphone = _IconLib.mic;
|
static const IconData microphone = _IconLib.mic;
|
||||||
|
|
||||||
|
/// Language icon
|
||||||
|
static const IconData language = _IconLib.languages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ class UiTypography {
|
|||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Title 1 Bold - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826)
|
||||||
|
/// Used for section headers and important labels.
|
||||||
|
static final TextStyle title1b = _primaryBase.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.5,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
/// Title 2 Bold - Font: Instrument Sans, Size: 20, Height: 1.1 (#121826)
|
/// Title 2 Bold - Font: Instrument Sans, Size: 20, Height: 1.1 (#121826)
|
||||||
static final TextStyle title2b = _primaryBase.copyWith(
|
static final TextStyle title2b = _primaryBase.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -264,6 +273,16 @@ class UiTypography {
|
|||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Title Uppercase 2 Bold - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.7 (#121826)
|
||||||
|
/// Used for section headers and important labels.
|
||||||
|
static final TextStyle titleUppercase2b = _primaryBase.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.5,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
/// Title Uppercase 3 Medium - Font: Instrument Sans, Size: 12, Height: 1.5, Spacing: 1.5 (#121826)
|
/// Title Uppercase 3 Medium - Font: Instrument Sans, Size: 12, Height: 1.5, Spacing: 1.5 (#121826)
|
||||||
static final TextStyle titleUppercase3m = _primaryBase.copyWith(
|
static final TextStyle titleUppercase3m = _primaryBase.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import '../ui_constants.dart';
|
|||||||
|
|
||||||
/// A custom button widget with different variants and icon support.
|
/// A custom button widget with different variants and icon support.
|
||||||
class UiButton extends StatelessWidget {
|
class UiButton extends StatelessWidget {
|
||||||
|
|
||||||
/// Creates a [UiButton] with a custom button builder.
|
/// Creates a [UiButton] with a custom button builder.
|
||||||
const UiButton({
|
const UiButton({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,6 +16,7 @@ class UiButton extends StatelessWidget {
|
|||||||
this.iconSize = 20,
|
this.iconSize = 20,
|
||||||
this.size = UiButtonSize.large,
|
this.size = UiButtonSize.large,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
this.isLoading = false,
|
||||||
}) : assert(
|
}) : assert(
|
||||||
text != null || child != null,
|
text != null || child != null,
|
||||||
'Either text or child must be provided',
|
'Either text or child must be provided',
|
||||||
@@ -34,6 +34,7 @@ class UiButton extends StatelessWidget {
|
|||||||
this.iconSize = 20,
|
this.iconSize = 20,
|
||||||
this.size = UiButtonSize.large,
|
this.size = UiButtonSize.large,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
this.isLoading = false,
|
||||||
}) : buttonBuilder = _elevatedButtonBuilder,
|
}) : buttonBuilder = _elevatedButtonBuilder,
|
||||||
assert(
|
assert(
|
||||||
text != null || child != null,
|
text != null || child != null,
|
||||||
@@ -52,6 +53,7 @@ class UiButton extends StatelessWidget {
|
|||||||
this.iconSize = 20,
|
this.iconSize = 20,
|
||||||
this.size = UiButtonSize.large,
|
this.size = UiButtonSize.large,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
this.isLoading = false,
|
||||||
}) : buttonBuilder = _outlinedButtonBuilder,
|
}) : buttonBuilder = _outlinedButtonBuilder,
|
||||||
assert(
|
assert(
|
||||||
text != null || child != null,
|
text != null || child != null,
|
||||||
@@ -70,6 +72,7 @@ class UiButton extends StatelessWidget {
|
|||||||
this.iconSize = 20,
|
this.iconSize = 20,
|
||||||
this.size = UiButtonSize.large,
|
this.size = UiButtonSize.large,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
this.isLoading = false,
|
||||||
}) : buttonBuilder = _textButtonBuilder,
|
}) : buttonBuilder = _textButtonBuilder,
|
||||||
assert(
|
assert(
|
||||||
text != null || child != null,
|
text != null || child != null,
|
||||||
@@ -88,11 +91,13 @@ class UiButton extends StatelessWidget {
|
|||||||
this.iconSize = 20,
|
this.iconSize = 20,
|
||||||
this.size = UiButtonSize.large,
|
this.size = UiButtonSize.large,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
this.isLoading = false,
|
||||||
}) : buttonBuilder = _textButtonBuilder,
|
}) : buttonBuilder = _textButtonBuilder,
|
||||||
assert(
|
assert(
|
||||||
text != null || child != null,
|
text != null || child != null,
|
||||||
'Either text or child must be provided',
|
'Either text or child must be provided',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The text to display on the button.
|
/// The text to display on the button.
|
||||||
final String? text;
|
final String? text;
|
||||||
|
|
||||||
@@ -129,6 +134,9 @@ class UiButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
buttonBuilder;
|
buttonBuilder;
|
||||||
|
|
||||||
|
/// Whether to show a loading indicator.
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// Builds the button UI.
|
/// Builds the button UI.
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -138,9 +146,9 @@ class UiButton extends StatelessWidget {
|
|||||||
|
|
||||||
final Widget button = buttonBuilder(
|
final Widget button = buttonBuilder(
|
||||||
context,
|
context,
|
||||||
onPressed,
|
isLoading ? null : onPressed,
|
||||||
mergedStyle,
|
mergedStyle,
|
||||||
_buildButtonContent(),
|
isLoading ? _buildLoadingContent() : _buildButtonContent(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fullWidth) {
|
if (fullWidth) {
|
||||||
@@ -150,6 +158,15 @@ class UiButton extends StatelessWidget {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the loading indicator.
|
||||||
|
Widget _buildLoadingContent() {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the style based on the button size.
|
/// Gets the style based on the button size.
|
||||||
ButtonStyle _getSizeStyle() {
|
ButtonStyle _getSizeStyle() {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
|
||||||
|
// token before we fire any mutations. Without this, there is a race
|
||||||
|
// condition where the gRPC layer sends the request unauthenticated
|
||||||
|
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
|
||||||
|
await firebaseUser.getIdToken(true);
|
||||||
|
|
||||||
// New user created successfully, proceed to create PostgreSQL entities
|
// New user created successfully, proceed to create PostgreSQL entities
|
||||||
return await _createBusinessAndUser(
|
return await _createBusinessAndUser(
|
||||||
firebaseUser: firebaseUser,
|
firebaseUser: firebaseUser,
|
||||||
@@ -165,6 +171,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force-refresh the ID token so the Data Connect SDK receives a valid
|
||||||
|
// bearer token before any subsequent Data Connect queries run.
|
||||||
|
await firebaseUser.getIdToken(true);
|
||||||
|
|
||||||
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
||||||
final bool hasBusinessAccount = await _checkBusinessUserExists(
|
final bool hasBusinessAccount = await _checkBusinessUserExists(
|
||||||
firebaseUser.uid,
|
firebaseUser.uid,
|
||||||
@@ -329,7 +339,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
try {
|
try {
|
||||||
await _service.auth.signOut();
|
await _service.auth.signOut();
|
||||||
dc.ClientSessionStore.instance.clear();
|
|
||||||
_service.clearCache();
|
_service.clearCache();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Error signing out: ${e.toString()}');
|
throw Exception('Error signing out: ${e.toString()}');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'domain/usecases/get_spending_breakdown.dart';
|
|||||||
import 'domain/usecases/approve_invoice.dart';
|
import 'domain/usecases/approve_invoice.dart';
|
||||||
import 'domain/usecases/dispute_invoice.dart';
|
import 'domain/usecases/dispute_invoice.dart';
|
||||||
import 'presentation/blocs/billing_bloc.dart';
|
import 'presentation/blocs/billing_bloc.dart';
|
||||||
|
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||||
import 'presentation/models/billing_invoice_model.dart';
|
import 'presentation/models/billing_invoice_model.dart';
|
||||||
import 'presentation/pages/billing_page.dart';
|
import 'presentation/pages/billing_page.dart';
|
||||||
import 'presentation/pages/completion_review_page.dart';
|
import 'presentation/pages/completion_review_page.dart';
|
||||||
@@ -44,6 +45,10 @@ class BillingModule extends Module {
|
|||||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i.add<ShiftCompletionReviewBloc>(
|
||||||
|
() => ShiftCompletionReviewBloc(
|
||||||
approveInvoice: i.get<ApproveInvoiceUseCase>(),
|
approveInvoice: i.get<ApproveInvoiceUseCase>(),
|
||||||
disputeInvoice: i.get<DisputeInvoiceUseCase>(),
|
disputeInvoice: i.get<DisputeInvoiceUseCase>(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import '../../domain/usecases/get_invoice_history.dart';
|
|||||||
import '../../domain/usecases/get_pending_invoices.dart';
|
import '../../domain/usecases/get_pending_invoices.dart';
|
||||||
import '../../domain/usecases/get_savings_amount.dart';
|
import '../../domain/usecases/get_savings_amount.dart';
|
||||||
import '../../domain/usecases/get_spending_breakdown.dart';
|
import '../../domain/usecases/get_spending_breakdown.dart';
|
||||||
import '../../domain/usecases/approve_invoice.dart';
|
|
||||||
import '../../domain/usecases/dispute_invoice.dart';
|
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
import '../models/spending_breakdown_model.dart';
|
import '../models/spending_breakdown_model.dart';
|
||||||
import 'billing_event.dart';
|
import 'billing_event.dart';
|
||||||
@@ -26,21 +24,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||||
required ApproveInvoiceUseCase approveInvoice,
|
|
||||||
required DisputeInvoiceUseCase disputeInvoice,
|
|
||||||
}) : _getBankAccounts = getBankAccounts,
|
}) : _getBankAccounts = getBankAccounts,
|
||||||
_getCurrentBillAmount = getCurrentBillAmount,
|
_getCurrentBillAmount = getCurrentBillAmount,
|
||||||
_getSavingsAmount = getSavingsAmount,
|
_getSavingsAmount = getSavingsAmount,
|
||||||
_getPendingInvoices = getPendingInvoices,
|
_getPendingInvoices = getPendingInvoices,
|
||||||
_getInvoiceHistory = getInvoiceHistory,
|
_getInvoiceHistory = getInvoiceHistory,
|
||||||
_getSpendingBreakdown = getSpendingBreakdown,
|
_getSpendingBreakdown = getSpendingBreakdown,
|
||||||
_approveInvoice = approveInvoice,
|
|
||||||
_disputeInvoice = disputeInvoice,
|
|
||||||
super(const BillingState()) {
|
super(const BillingState()) {
|
||||||
on<BillingLoadStarted>(_onLoadStarted);
|
on<BillingLoadStarted>(_onLoadStarted);
|
||||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||||
on<BillingInvoiceApproved>(_onInvoiceApproved);
|
|
||||||
on<BillingInvoiceDisputed>(_onInvoiceDisputed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetBankAccountsUseCase _getBankAccounts;
|
final GetBankAccountsUseCase _getBankAccounts;
|
||||||
@@ -49,8 +41,6 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
||||||
final ApproveInvoiceUseCase _approveInvoice;
|
|
||||||
final DisputeInvoiceUseCase _disputeInvoice;
|
|
||||||
|
|
||||||
Future<void> _onLoadStarted(
|
Future<void> _onLoadStarted(
|
||||||
BillingLoadStarted event,
|
BillingLoadStarted event,
|
||||||
@@ -78,10 +68,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
results[5] as List<BusinessBankAccount>;
|
results[5] as List<BusinessBankAccount>;
|
||||||
|
|
||||||
// Map Domain Entities to Presentation Models
|
// Map Domain Entities to Presentation Models
|
||||||
final List<BillingInvoice> uiPendingInvoices =
|
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
|
||||||
pendingInvoices.map(_mapInvoiceToUiModel).toList();
|
.map(_mapInvoiceToUiModel)
|
||||||
final List<BillingInvoice> uiInvoiceHistory =
|
.toList();
|
||||||
invoiceHistory.map(_mapInvoiceToUiModel).toList();
|
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
|
||||||
|
.map(_mapInvoiceToUiModel)
|
||||||
|
.toList();
|
||||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||||
_mapSpendingItemsToUiModel(spendingItems);
|
_mapSpendingItemsToUiModel(spendingItems);
|
||||||
final double periodTotal = uiSpendingBreakdown.fold(
|
final double periodTotal = uiSpendingBreakdown.fold(
|
||||||
@@ -101,10 +93,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => state.copyWith(
|
onError: (String errorKey) =>
|
||||||
status: BillingStatus.failure,
|
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
||||||
errorMessage: errorKey,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +105,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<InvoiceItem> spendingItems =
|
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
|
||||||
await _getSpendingBreakdown.call(event.period);
|
.call(event.period);
|
||||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||||
_mapSpendingItemsToUiModel(spendingItems);
|
_mapSpendingItemsToUiModel(spendingItems);
|
||||||
final double periodTotal = uiSpendingBreakdown.fold(
|
final double periodTotal = uiSpendingBreakdown.fold(
|
||||||
@@ -131,46 +121,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => state.copyWith(
|
onError: (String errorKey) =>
|
||||||
status: BillingStatus.failure,
|
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
||||||
errorMessage: errorKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onInvoiceApproved(
|
|
||||||
BillingInvoiceApproved event,
|
|
||||||
Emitter<BillingState> emit,
|
|
||||||
) async {
|
|
||||||
await handleError(
|
|
||||||
emit: emit.call,
|
|
||||||
action: () async {
|
|
||||||
await _approveInvoice.call(event.invoiceId);
|
|
||||||
add(const BillingLoadStarted());
|
|
||||||
},
|
|
||||||
onError: (String errorKey) => state.copyWith(
|
|
||||||
status: BillingStatus.failure,
|
|
||||||
errorMessage: errorKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onInvoiceDisputed(
|
|
||||||
BillingInvoiceDisputed event,
|
|
||||||
Emitter<BillingState> emit,
|
|
||||||
) async {
|
|
||||||
await handleError(
|
|
||||||
emit: emit.call,
|
|
||||||
action: () async {
|
|
||||||
await _disputeInvoice.call(
|
|
||||||
DisputeInvoiceParams(id: event.invoiceId, reason: event.reason),
|
|
||||||
);
|
|
||||||
add(const BillingLoadStarted());
|
|
||||||
},
|
|
||||||
onError: (String errorKey) => state.copyWith(
|
|
||||||
status: BillingStatus.failure,
|
|
||||||
errorMessage: errorKey,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,15 +132,18 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
? 'N/A'
|
? 'N/A'
|
||||||
: formatter.format(invoice.issueDate!);
|
: formatter.format(invoice.issueDate!);
|
||||||
|
|
||||||
final List<BillingWorkerRecord> workers = invoice.workers.map((InvoiceWorker w) {
|
final List<BillingWorkerRecord> workers = invoice.workers.map((
|
||||||
|
InvoiceWorker w,
|
||||||
|
) {
|
||||||
|
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||||
return BillingWorkerRecord(
|
return BillingWorkerRecord(
|
||||||
workerName: w.name,
|
workerName: w.name,
|
||||||
roleName: w.role,
|
roleName: w.role,
|
||||||
totalAmount: w.amount,
|
totalAmount: w.amount,
|
||||||
hours: w.hours,
|
hours: w.hours,
|
||||||
rate: w.rate,
|
rate: w.rate,
|
||||||
startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--',
|
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
|
||||||
endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--',
|
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
|
||||||
breakMinutes: w.breakMinutes,
|
breakMinutes: w.breakMinutes,
|
||||||
workerAvatarUrl: w.avatarUrl,
|
workerAvatarUrl: w.avatarUrl,
|
||||||
);
|
);
|
||||||
@@ -197,32 +152,34 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
String? overallStart;
|
String? overallStart;
|
||||||
String? overallEnd;
|
String? overallEnd;
|
||||||
|
|
||||||
// Find valid times from workers instead of just taking the first one
|
// Find valid times from actual DateTime checks to ensure chronological sorting
|
||||||
final validStartTimes = workers
|
final List<DateTime> validCheckIns = invoice.workers
|
||||||
.where((w) => w.startTime != '--:--')
|
.where((InvoiceWorker w) => w.checkIn != null)
|
||||||
.map((w) => w.startTime)
|
.map((InvoiceWorker w) => w.checkIn!)
|
||||||
.toList();
|
.toList();
|
||||||
final validEndTimes = workers
|
final List<DateTime> validCheckOuts = invoice.workers
|
||||||
.where((w) => w.endTime != '--:--')
|
.where((InvoiceWorker w) => w.checkOut != null)
|
||||||
.map((w) => w.endTime)
|
.map((InvoiceWorker w) => w.checkOut!)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (validStartTimes.isNotEmpty) {
|
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||||
validStartTimes.sort();
|
|
||||||
overallStart = validStartTimes.first;
|
if (validCheckIns.isNotEmpty) {
|
||||||
|
validCheckIns.sort();
|
||||||
|
overallStart = timeFormat.format(validCheckIns.first);
|
||||||
} else if (workers.isNotEmpty) {
|
} else if (workers.isNotEmpty) {
|
||||||
overallStart = workers.first.startTime;
|
overallStart = workers.first.startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validEndTimes.isNotEmpty) {
|
if (validCheckOuts.isNotEmpty) {
|
||||||
validEndTimes.sort();
|
validCheckOuts.sort();
|
||||||
overallEnd = validEndTimes.last;
|
overallEnd = timeFormat.format(validCheckOuts.last);
|
||||||
} else if (workers.isNotEmpty) {
|
} else if (workers.isNotEmpty) {
|
||||||
overallEnd = workers.first.endTime;
|
overallEnd = workers.first.endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BillingInvoice(
|
return BillingInvoice(
|
||||||
id: invoice.invoiceNumber ?? invoice.id,
|
id: invoice.id,
|
||||||
title: invoice.title ?? 'N/A',
|
title: invoice.title ?? 'N/A',
|
||||||
locationAddress: invoice.locationAddress ?? 'Remote',
|
locationAddress: invoice.locationAddress ?? 'Remote',
|
||||||
clientName: invoice.clientName ?? 'N/A',
|
clientName: invoice.clientName ?? 'N/A',
|
||||||
|
|||||||
@@ -24,20 +24,3 @@ class BillingPeriodChanged extends BillingEvent {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[period];
|
List<Object?> get props => <Object?>[period];
|
||||||
}
|
}
|
||||||
|
|
||||||
class BillingInvoiceApproved extends BillingEvent {
|
|
||||||
const BillingInvoiceApproved(this.invoiceId);
|
|
||||||
final String invoiceId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[invoiceId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class BillingInvoiceDisputed extends BillingEvent {
|
|
||||||
const BillingInvoiceDisputed(this.invoiceId, this.reason);
|
|
||||||
final String invoiceId;
|
|
||||||
final String reason;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[invoiceId, reason];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class BillingInvoice extends Equatable {
|
|||||||
required this.workersCount,
|
required this.workersCount,
|
||||||
required this.totalHours,
|
required this.totalHours,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.workers = const [],
|
this.workers = const <BillingWorkerRecord>[],
|
||||||
this.startTime,
|
this.startTime,
|
||||||
this.endTime,
|
this.endTime,
|
||||||
});
|
});
|
||||||
@@ -70,7 +70,7 @@ class BillingWorkerRecord extends Equatable {
|
|||||||
final String? workerAvatarUrl;
|
final String? workerAvatarUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => <Object?>[
|
||||||
workerName,
|
workerName,
|
||||||
roleName,
|
roleName,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import '../blocs/billing_bloc.dart';
|
|||||||
import '../blocs/billing_event.dart';
|
import '../blocs/billing_event.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import '../blocs/billing_state.dart';
|
||||||
import '../widgets/invoice_history_section.dart';
|
import '../widgets/invoice_history_section.dart';
|
||||||
import '../widgets/payment_method_card.dart';
|
|
||||||
import '../widgets/pending_invoices_section.dart';
|
import '../widgets/pending_invoices_section.dart';
|
||||||
import '../widgets/spending_breakdown_card.dart';
|
import '../widgets/spending_breakdown_card.dart';
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
leading: Center(
|
leading: Center(
|
||||||
child: UiIconButton(
|
child: UiIconButton(
|
||||||
icon: UiIcons.arrowLeft,
|
icon: UiIcons.arrowLeft,
|
||||||
backgroundColor: UiColors.white.withOpacity(0.15),
|
backgroundColor: UiColors.white.withValues(alpha: 0.15),
|
||||||
iconColor: UiColors.white,
|
iconColor: UiColors.white,
|
||||||
useBlur: true,
|
useBlur: true,
|
||||||
size: 40,
|
size: 40,
|
||||||
@@ -106,21 +105,21 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
t.client_billing.title,
|
t.client_billing.title,
|
||||||
style: UiTypography.headline3b.copyWith(color: UiColors.white),
|
style: UiTypography.headline3b.copyWith(
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Padding(
|
background: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(bottom: UiConstants.space8),
|
||||||
bottom: UiConstants.space8,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.current_period,
|
t.client_billing.current_period,
|
||||||
style: UiTypography.footnote2r.copyWith(
|
style: UiTypography.footnote2r.copyWith(
|
||||||
color: UiColors.white.withOpacity(0.7),
|
color: UiColors.white.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
@@ -224,156 +223,13 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||||
],
|
],
|
||||||
const PaymentMethodCard(),
|
// const PaymentMethodCard(),
|
||||||
const SpendingBreakdownCard(),
|
const SpendingBreakdownCard(),
|
||||||
_buildSavingsCard(state.savings),
|
|
||||||
if (state.invoiceHistory.isNotEmpty)
|
if (state.invoiceHistory.isNotEmpty)
|
||||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||||
|
const SizedBox(height: UiConstants.space16),
|
||||||
_buildExportButton(),
|
|
||||||
const SizedBox(height: UiConstants.space12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSavingsCard(double amount) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFFFBEB),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: UiColors.accent.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.accent,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
),
|
|
||||||
child: const Icon(UiIcons.trendingDown, size: 18, color: UiColors.accentForeground),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.client_billing.rate_optimization_title,
|
|
||||||
style: UiTypography.body2b.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
children: [
|
|
||||||
TextSpan(text: t.client_billing.rate_optimization_save),
|
|
||||||
TextSpan(
|
|
||||||
text: t.client_billing.rate_optimization_amount(amount: amount.toStringAsFixed(0)),
|
|
||||||
style: UiTypography.footnote2b.textPrimary,
|
|
||||||
),
|
|
||||||
TextSpan(text: t.client_billing.rate_optimization_shifts),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
SizedBox(
|
|
||||||
height: 32,
|
|
||||||
child: UiButton.primary(
|
|
||||||
text: t.client_billing.view_details,
|
|
||||||
onPressed: () {},
|
|
||||||
size: UiButtonSize.small,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildExportButton() {
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: UiButton.secondary(
|
|
||||||
text: t.client_billing.export_button,
|
|
||||||
leadingIcon: UiIcons.download,
|
|
||||||
onPressed: () {},
|
|
||||||
size: UiButtonSize.large,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: UiConstants.space12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.bgPopup,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
UiIcons.file,
|
|
||||||
size: 48,
|
|
||||||
color: UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Text(
|
|
||||||
t.client_billing.no_invoices_period,
|
|
||||||
style: UiTypography.body1m.textSecondary,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InvoicesReadyBanner extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => Modular.to.toInvoiceReady(),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.success.withValues(alpha: 0.1),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: UiColors.success.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(UiIcons.file, color: UiColors.success),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.client_billing.invoices_ready_title,
|
|
||||||
style: UiTypography.body1b.copyWith(color: UiColors.success),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
t.client_billing.invoices_ready_subtitle,
|
|
||||||
style: UiTypography.footnote2r.copyWith(color: UiColors.success),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(UiIcons.chevronRight, color: UiColors.success),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
|
||||||
import '../blocs/billing_bloc.dart';
|
|
||||||
import '../blocs/billing_event.dart';
|
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_actions.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_amount.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_info.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||||
|
|
||||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||||
@@ -14,7 +16,8 @@ class ShiftCompletionReviewPage extends StatefulWidget {
|
|||||||
final BillingInvoice? invoice;
|
final BillingInvoice? invoice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ShiftCompletionReviewPage> createState() => _ShiftCompletionReviewPageState();
|
State<ShiftCompletionReviewPage> createState() =>
|
||||||
|
_ShiftCompletionReviewPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||||
@@ -26,395 +29,65 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Use widget.invoice if provided, else try to get from arguments
|
// Use widget.invoice if provided, else try to get from arguments
|
||||||
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice;
|
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((BillingWorkerRecord w) {
|
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
|
||||||
|
BillingWorkerRecord w,
|
||||||
|
) {
|
||||||
if (searchQuery.isEmpty) return true;
|
if (searchQuery.isEmpty) return true;
|
||||||
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8FAFC),
|
appBar: UiAppBar(
|
||||||
|
title: invoice.title,
|
||||||
|
subtitle: invoice.clientName,
|
||||||
|
showBackButton: true,
|
||||||
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
_buildHeader(context),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
_buildInvoiceInfoCard(),
|
CompletionReviewInfo(invoice: invoice),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
_buildAmountCard(),
|
CompletionReviewAmount(invoice: invoice),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
_buildWorkersHeader(),
|
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||||
const SizedBox(height: UiConstants.space4),
|
// const SizedBox(height: UiConstants.space4),
|
||||||
_buildSearchAndTabs(),
|
// CompletionReviewSearchAndTabs(
|
||||||
const SizedBox(height: UiConstants.space4),
|
// selectedTab: selectedTab,
|
||||||
...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)),
|
// workersCount: invoice.workersCount,
|
||||||
const SizedBox(height: UiConstants.space6),
|
// onTabChanged: (int index) =>
|
||||||
_buildActionButtons(context),
|
// setState(() => selectedTab = index),
|
||||||
const SizedBox(height: UiConstants.space4),
|
// onSearchChanged: (String val) =>
|
||||||
_buildDownloadLink(),
|
// setState(() => searchQuery = val),
|
||||||
const SizedBox(height: UiConstants.space8),
|
// ),
|
||||||
|
// const SizedBox(height: UiConstants.space4),
|
||||||
|
// ...filteredWorkers.map(
|
||||||
|
// (BillingWorkerRecord worker) =>
|
||||||
|
// CompletionReviewWorkerCard(worker: worker),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: UiConstants.space4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
bottomNavigationBar: Container(
|
||||||
),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
border: Border(bottom: BorderSide(color: UiColors.border)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.border,
|
|
||||||
borderRadius: UiConstants.radiusFull,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(t.client_billing.invoice_ready, style: UiTypography.headline4b.textPrimary),
|
|
||||||
Text(t.client_billing.review_and_approve_subtitle, style: UiTypography.body2r.textSecondary),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
UiIconButton.secondary(
|
|
||||||
icon: UiIcons.close,
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInvoiceInfoCard() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
|
||||||
Text(invoice.clientName, style: UiTypography.body2r.textSecondary),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
_buildInfoRow(UiIcons.clock, '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}'),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoRow(IconData icon, String text) {
|
|
||||||
return Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Text(text, style: UiTypography.body2r.textSecondary),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAmountCard() {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEFF6FF),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: const Color(0xFFDBEAFE)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
t.client_billing.total_amount_label,
|
|
||||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
Text(
|
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
|
||||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
|
||||||
style: UiTypography.footnote2b.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWorkersHeader() {
|
|
||||||
return Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Text(
|
|
||||||
t.client_billing.workers_tab.title(count: invoice.workersCount),
|
|
||||||
style: UiTypography.title2b.textPrimary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSearchAndTabs() {
|
|
||||||
return Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF1F5F9),
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
),
|
|
||||||
child: TextField(
|
|
||||||
onChanged: (String val) => setState(() => searchQuery = val),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
icon: const Icon(UiIcons.search, size: 18, color: UiColors.iconSecondary),
|
|
||||||
hintText: t.client_billing.workers_tab.search_hint,
|
|
||||||
hintStyle: UiTypography.body2r.textSecondary,
|
|
||||||
border: InputBorder.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Expanded(
|
|
||||||
child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTabButton(String text, int index) {
|
|
||||||
final bool isSelected = selectedTab == index;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => setState(() => selectedTab = index),
|
|
||||||
child: Container(
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: UiTypography.body2b.copyWith(
|
|
||||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWorkerCard(BillingWorkerRecord worker) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
border: Border(
|
||||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 20,
|
|
||||||
backgroundColor: UiColors.bgSecondary,
|
|
||||||
backgroundImage: worker.workerAvatarUrl != null ? NetworkImage(worker.workerAvatarUrl!) : null,
|
|
||||||
child: worker.workerAvatarUrl == null ? const Icon(UiIcons.user, size: 20, color: UiColors.iconSecondary) : null,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(worker.workerName, style: UiTypography.body1b.textPrimary),
|
|
||||||
Text(worker.roleName, style: UiTypography.footnote2r.textSecondary),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Column(
|
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: <Widget>[
|
|
||||||
Text('\$${worker.totalAmount.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary),
|
|
||||||
Text('${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', style: UiTypography.footnote2r.textSecondary),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Text('${worker.startTime} - ${worker.endTime}', style: UiTypography.footnote2b.textPrimary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(UiIcons.coffee, size: 12, color: UiColors.iconSecondary),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text('${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', style: UiTypography.footnote2r.textSecondary),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
UiIconButton.secondary(
|
|
||||||
icon: UiIcons.edit,
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
UiIconButton.secondary(
|
|
||||||
icon: UiIcons.warning,
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButtons(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: <Widget>[
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: UiButton.primary(
|
|
||||||
text: t.client_billing.actions.approve_pay,
|
|
||||||
leadingIcon: UiIcons.checkCircle,
|
|
||||||
onPressed: () {
|
|
||||||
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoice.id));
|
|
||||||
Modular.to.pop();
|
|
||||||
UiSnackbar.show(context, message: t.client_billing.approved_success, type: UiSnackbarType.success);
|
|
||||||
},
|
|
||||||
size: UiButtonSize.large,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF22C55E),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: Colors.orange, width: 2),
|
|
||||||
),
|
|
||||||
child: UiButton.secondary(
|
|
||||||
text: t.client_billing.actions.flag_review,
|
|
||||||
leadingIcon: UiIcons.warning,
|
|
||||||
onPressed: () => _showFlagDialog(context),
|
|
||||||
size: UiButtonSize.large,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.orange,
|
|
||||||
side: BorderSide.none,
|
|
||||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDownloadLink() {
|
|
||||||
return Center(
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
|
|
||||||
label: Text(
|
|
||||||
t.client_billing.actions.download_pdf,
|
|
||||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showFlagDialog(BuildContext context) {
|
|
||||||
final controller = TextEditingController();
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => AlertDialog(
|
|
||||||
title: Text(t.client_billing.flag_dialog.title),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: t.client_billing.flag_dialog.hint,
|
|
||||||
),
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (controller.text.isNotEmpty) {
|
|
||||||
Modular.get<BillingBloc>().add(
|
|
||||||
BillingInvoiceDisputed(invoice.id, controller.text),
|
|
||||||
);
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
Modular.to.pop();
|
|
||||||
UiSnackbar.show(context, message: t.client_billing.flagged_success, type: UiSnackbarType.warning);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(t.client_billing.flag_dialog.button),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
import '../blocs/billing_bloc.dart';
|
import '../blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_event.dart';
|
import '../blocs/billing_event.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import '../blocs/billing_state.dart';
|
||||||
@@ -25,15 +26,9 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true),
|
||||||
title: const Text('Invoices Ready'),
|
|
||||||
leading: UiIconButton.secondary(
|
|
||||||
icon: UiIcons.arrowLeft,
|
|
||||||
onTap: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: BlocBuilder<BillingBloc, BillingState>(
|
body: BlocBuilder<BillingBloc, BillingState>(
|
||||||
builder: (context, state) {
|
builder: (BuildContext context, BillingState state) {
|
||||||
if (state.status == BillingStatus.loading) {
|
if (state.status == BillingStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -42,8 +37,12 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: <Widget>[
|
||||||
const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary),
|
const Icon(
|
||||||
|
UiIcons.file,
|
||||||
|
size: 64,
|
||||||
|
color: UiColors.iconSecondary,
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Text(
|
Text(
|
||||||
'No invoices ready yet',
|
'No invoices ready yet',
|
||||||
@@ -57,9 +56,10 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
itemCount: state.invoiceHistory.length,
|
itemCount: state.invoiceHistory.length,
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
separatorBuilder: (BuildContext context, int index) =>
|
||||||
itemBuilder: (context, index) {
|
const SizedBox(height: 16),
|
||||||
final invoice = state.invoiceHistory[index];
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||||
return _InvoiceSummaryCard(invoice: invoice);
|
return _InvoiceSummaryCard(invoice: invoice);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -81,7 +81,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
|||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border),
|
||||||
boxShadow: [
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
@@ -91,40 +91,51 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.success.withValues(alpha: 0.1),
|
color: UiColors.success.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'READY',
|
'READY',
|
||||||
style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success),
|
style: UiTypography.titleUppercase4b.copyWith(
|
||||||
|
color: UiColors.success,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
invoice.date,
|
|
||||||
style: UiTypography.footnote2r.textTertiary,
|
|
||||||
),
|
),
|
||||||
|
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary),
|
Text(
|
||||||
|
invoice.locationAddress,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary),
|
Text(
|
||||||
Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary),
|
'TOTAL AMOUNT',
|
||||||
|
style: UiTypography.titleUppercase4m.textSecondary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||||
|
style: UiTypography.title2b.primary,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
UiButton.primary(
|
UiButton.primary(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import '../blocs/billing_bloc.dart';
|
import '../blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import '../blocs/billing_state.dart';
|
||||||
@@ -20,6 +21,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
|||||||
appBar: UiAppBar(
|
appBar: UiAppBar(
|
||||||
title: t.client_billing.awaiting_approval,
|
title: t.client_billing.awaiting_approval,
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
|
onLeadingPressed: () => Modular.to.toClientBilling(),
|
||||||
),
|
),
|
||||||
body: _buildBody(context, state),
|
body: _buildBody(context, state),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: () {}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,40 +14,18 @@ class InvoiceHistorySection extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.invoice_history,
|
t.client_billing.invoice_history,
|
||||||
style: UiTypography.title2b.textPrimary,
|
style: UiTypography.title2b.textPrimary,
|
||||||
),
|
),
|
||||||
TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.client_billing.view_all,
|
|
||||||
style: UiTypography.body2b.copyWith(color: UiColors.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Icon(UiIcons.chevronRight, size: 16, color: UiColors.primary),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
boxShadow: <BoxShadow>[
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
@@ -99,7 +77,7 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
UiIcons.file,
|
UiIcons.file,
|
||||||
color: UiColors.iconSecondary.withOpacity(0.6),
|
color: UiColors.iconSecondary.withValues(alpha: 0.6),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -108,10 +86,7 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(invoice.title, style: UiTypography.body1r.textPrimary),
|
||||||
invoice.id,
|
|
||||||
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
invoice.date,
|
invoice.date,
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
@@ -129,12 +104,6 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
_StatusBadge(status: invoice.status),
|
_StatusBadge(status: invoice.status),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space4),
|
|
||||||
Icon(
|
|
||||||
UiIcons.download,
|
|
||||||
size: 20,
|
|
||||||
color: UiColors.iconSecondary.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,18 +21,11 @@ class PendingInvoicesSection extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Modular.to.toAwaitingApproval(),
|
onTap: () => Modular.to.toAwaitingApproval(),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.black.withOpacity(0.04),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -48,9 +41,9 @@ class PendingInvoicesSection extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.awaiting_approval,
|
t.client_billing.awaiting_approval,
|
||||||
style: UiTypography.body1b.textPrimary,
|
style: UiTypography.body1b.textPrimary,
|
||||||
@@ -86,7 +79,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
|||||||
Icon(
|
Icon(
|
||||||
UiIcons.chevronRight,
|
UiIcons.chevronRight,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: UiColors.iconSecondary.withOpacity(0.5),
|
color: UiColors.iconSecondary.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -115,6 +108,8 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
@@ -134,8 +129,6 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
@@ -187,7 +180,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 32,
|
height: 32,
|
||||||
color: UiColors.border.withOpacity(0.3),
|
color: UiColors.border.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatItem(
|
child: _buildStatItem(
|
||||||
@@ -199,7 +192,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 32,
|
height: 32,
|
||||||
color: UiColors.border.withOpacity(0.3),
|
color: UiColors.border.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatItem(
|
child: _buildStatItem(
|
||||||
@@ -232,7 +225,11 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
Widget _buildStatItem(IconData icon, String value, String label) {
|
Widget _buildStatItem(IconData icon, String value, String label) {
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)),
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: UiColors.iconSecondary.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
|||||||
_onRouteChanged();
|
_onRouteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const List<String> _hideBottomBarPaths = <String>[
|
||||||
|
ClientPaths.completionReview,
|
||||||
|
ClientPaths.awaitingApproval,
|
||||||
|
];
|
||||||
|
|
||||||
void _onRouteChanged() {
|
void _onRouteChanged() {
|
||||||
final String path = Modular.to.path;
|
final String path = Modular.to.path;
|
||||||
int newIndex = state.currentIndex;
|
int newIndex = state.currentIndex;
|
||||||
|
|
||||||
// Detect which tab is active based on the route path
|
// Detect which tab is active based on the route path
|
||||||
// Using contains() to handle child routes and trailing slashes
|
|
||||||
if (path.contains(ClientPaths.coverage)) {
|
if (path.contains(ClientPaths.coverage)) {
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
} else if (path.contains(ClientPaths.billing)) {
|
} else if (path.contains(ClientPaths.billing)) {
|
||||||
@@ -27,8 +31,13 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
|||||||
newIndex = 4;
|
newIndex = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newIndex != state.currentIndex) {
|
final bool showBottomBar = !_hideBottomBarPaths.any(path.contains);
|
||||||
emit(state.copyWith(currentIndex: newIndex));
|
|
||||||
|
if (newIndex != state.currentIndex ||
|
||||||
|
showBottomBar != state.showBottomBar) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,19 +46,19 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
|||||||
|
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
Modular.to.navigate(ClientPaths.coverage);
|
Modular.to.toClientCoverage();
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
Modular.to.navigate(ClientPaths.billing);
|
Modular.to.toClientBilling();
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
Modular.to.navigate(ClientPaths.home);
|
Modular.to.toClientHome();
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
Modular.to.navigate(ClientPaths.orders);
|
Modular.to.toClientOrders();
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
Modular.to.navigate(ClientPaths.reports);
|
Modular.to.toClientReports();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// State update will happen via _onRouteChanged
|
// State update will happen via _onRouteChanged
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart';
|
|||||||
class ClientMainState extends Equatable {
|
class ClientMainState extends Equatable {
|
||||||
const ClientMainState({
|
const ClientMainState({
|
||||||
this.currentIndex = 2, // Default to Home
|
this.currentIndex = 2, // Default to Home
|
||||||
|
this.showBottomBar = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
|
final bool showBottomBar;
|
||||||
|
|
||||||
ClientMainState copyWith({int? currentIndex}) {
|
ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
|
||||||
return ClientMainState(currentIndex: currentIndex ?? this.currentIndex);
|
return ClientMainState(
|
||||||
|
currentIndex: currentIndex ?? this.currentIndex,
|
||||||
|
showBottomBar: showBottomBar ?? this.showBottomBar,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[currentIndex];
|
List<Object> get props => <Object>[currentIndex, showBottomBar];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class ClientMainPage extends StatelessWidget {
|
|||||||
body: const RouterOutlet(),
|
body: const RouterOutlet(),
|
||||||
bottomNavigationBar: BlocBuilder<ClientMainCubit, ClientMainState>(
|
bottomNavigationBar: BlocBuilder<ClientMainCubit, ClientMainState>(
|
||||||
builder: (BuildContext context, ClientMainState state) {
|
builder: (BuildContext context, ClientMainState state) {
|
||||||
|
if (!state.showBottomBar) return const SizedBox.shrink();
|
||||||
|
|
||||||
return ClientMainBottomBar(
|
return ClientMainBottomBar(
|
||||||
currentIndex: state.currentIndex,
|
currentIndex: state.currentIndex,
|
||||||
onTap: (int index) {
|
onTap: (int index) {
|
||||||
|
|||||||
@@ -99,16 +99,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<UserSessionData> getUserSessionData() async {
|
Future<UserSessionData> getUserSessionData() async {
|
||||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
|
||||||
final dc.ClientBusinessSession? business = session?.business;
|
|
||||||
|
|
||||||
if (business != null) {
|
|
||||||
return UserSessionData(
|
|
||||||
businessName: business.businessName,
|
|
||||||
photoUrl: business.companyLogoUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _service.run(() async {
|
return await _service.run(() async {
|
||||||
final String businessId = await _service.getBusinessId();
|
final String businessId = await _service.getBusinessId();
|
||||||
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
|
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
title: i18n.rapid,
|
title: i18n.rapid,
|
||||||
subtitle: i18n.rapid_subtitle,
|
subtitle: i18n.rapid_subtitle,
|
||||||
icon: UiIcons.zap,
|
icon: UiIcons.zap,
|
||||||
color: UiColors.tagError,
|
color: UiColors.tagError.withValues(alpha: 0.5),
|
||||||
borderColor: UiColors.borderError.withValues(alpha: 0.3),
|
borderColor: UiColors.borderError.withValues(alpha: 0.3),
|
||||||
iconBgColor: UiColors.white,
|
iconBgColor: UiColors.white,
|
||||||
iconColor: UiColors.textError,
|
iconColor: UiColors.textError,
|
||||||
|
|||||||
@@ -26,15 +26,16 @@ import 'presentation/pages/recurring_order_page.dart';
|
|||||||
/// presentation layer BLoCs.
|
/// presentation layer BLoCs.
|
||||||
class ClientCreateOrderModule extends Module {
|
class ClientCreateOrderModule extends Module {
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[DataConnectModule()];
|
List<Module> get imports => <Module>[DataConnectModule(), CoreModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
|
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
|
||||||
(Injector i) => ClientCreateOrderRepositoryImpl(
|
() => ClientCreateOrderRepositoryImpl(
|
||||||
service: i.get<dc.DataConnectService>(),
|
service: i.get<dc.DataConnectService>(),
|
||||||
rapidOrderService: i.get<RapidOrderService>(),
|
rapidOrderService: i.get<RapidOrderService>(),
|
||||||
|
fileUploadService: i.get<FileUploadService>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ class ClientCreateOrderModule extends Module {
|
|||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<RapidOrderBloc>(
|
i.add<RapidOrderBloc>(
|
||||||
(Injector i) => RapidOrderBloc(
|
() => RapidOrderBloc(
|
||||||
i.get<TranscribeRapidOrderUseCase>(),
|
i.get<TranscribeRapidOrderUseCase>(),
|
||||||
i.get<ParseRapidOrderTextToOrderUseCase>(),
|
i.get<ParseRapidOrderTextToOrderUseCase>(),
|
||||||
i.get<AudioRecorderService>(),
|
i.get<AudioRecorderService>(),
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
ClientCreateOrderRepositoryImpl({
|
ClientCreateOrderRepositoryImpl({
|
||||||
required dc.DataConnectService service,
|
required dc.DataConnectService service,
|
||||||
required RapidOrderService rapidOrderService,
|
required RapidOrderService rapidOrderService,
|
||||||
|
required FileUploadService fileUploadService,
|
||||||
}) : _service = service,
|
}) : _service = service,
|
||||||
_rapidOrderService = rapidOrderService;
|
_rapidOrderService = rapidOrderService,
|
||||||
|
_fileUploadService = fileUploadService;
|
||||||
|
|
||||||
final dc.DataConnectService _service;
|
final dc.DataConnectService _service;
|
||||||
final RapidOrderService _rapidOrderService;
|
final RapidOrderService _rapidOrderService;
|
||||||
|
final FileUploadService _fileUploadService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
|
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
|
||||||
@@ -379,29 +382,82 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
);
|
);
|
||||||
final RapidOrderParsedData data = response.parsed;
|
final RapidOrderParsedData data = response.parsed;
|
||||||
|
|
||||||
|
// Fetch Business ID
|
||||||
|
final String businessId = await _service.getBusinessId();
|
||||||
|
|
||||||
|
// 1. Hub Matching
|
||||||
|
final OperationResult<
|
||||||
|
dc.ListTeamHubsByOwnerIdData,
|
||||||
|
dc.ListTeamHubsByOwnerIdVariables
|
||||||
|
>
|
||||||
|
hubResult = await _service.connector
|
||||||
|
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||||
|
.execute();
|
||||||
|
final List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs = hubResult.data.teamHubs;
|
||||||
|
|
||||||
|
final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub(
|
||||||
|
hubs,
|
||||||
|
data.locationHint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Roles Matching
|
||||||
|
// We fetch vendors to get the first one as a context for role matching.
|
||||||
|
final OperationResult<dc.ListVendorsData, void> vendorResult =
|
||||||
|
await _service.connector.listVendors().execute();
|
||||||
|
final List<dc.ListVendorsVendors> vendors = vendorResult.data.vendors;
|
||||||
|
|
||||||
|
String? selectedVendorId;
|
||||||
|
List<dc.ListRolesByVendorIdRoles> availableRoles =
|
||||||
|
<dc.ListRolesByVendorIdRoles>[];
|
||||||
|
|
||||||
|
if (vendors.isNotEmpty) {
|
||||||
|
selectedVendorId = vendors.first.id;
|
||||||
|
final OperationResult<
|
||||||
|
dc.ListRolesByVendorIdData,
|
||||||
|
dc.ListRolesByVendorIdVariables
|
||||||
|
>
|
||||||
|
roleResult = await _service.connector
|
||||||
|
.listRolesByVendorId(vendorId: selectedVendorId)
|
||||||
|
.execute();
|
||||||
|
availableRoles = roleResult.data.roles;
|
||||||
|
}
|
||||||
|
|
||||||
final DateTime startAt =
|
final DateTime startAt =
|
||||||
DateTime.tryParse(data.startAt ?? '') ?? DateTime.now();
|
DateTime.tryParse(data.startAt ?? '') ?? DateTime.now();
|
||||||
final DateTime endAt =
|
final DateTime endAt =
|
||||||
DateTime.tryParse(data.endAt ?? '') ??
|
DateTime.tryParse(data.endAt ?? '') ??
|
||||||
startAt.add(const Duration(hours: 8));
|
startAt.add(const Duration(hours: 8));
|
||||||
|
|
||||||
final String startTimeStr = DateFormat('hh:mm a').format(startAt);
|
final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal());
|
||||||
final String endTimeStr = DateFormat('hh:mm a').format(endAt);
|
final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal());
|
||||||
|
|
||||||
return domain.OneTimeOrder(
|
return domain.OneTimeOrder(
|
||||||
date: startAt,
|
date: startAt,
|
||||||
location: data.locationHint ?? '',
|
location: bestHub?.hubName ?? data.locationHint ?? '',
|
||||||
eventName: data.notes ?? '',
|
eventName: data.notes ?? '',
|
||||||
hub: data.locationHint != null
|
vendorId: selectedVendorId,
|
||||||
|
hub: bestHub != null
|
||||||
? domain.OneTimeOrderHubDetails(
|
? domain.OneTimeOrderHubDetails(
|
||||||
id: '',
|
id: bestHub.id,
|
||||||
name: data.locationHint!,
|
name: bestHub.hubName,
|
||||||
address: '',
|
address: bestHub.address,
|
||||||
|
placeId: bestHub.placeId,
|
||||||
|
latitude: bestHub.latitude ?? 0,
|
||||||
|
longitude: bestHub.longitude ?? 0,
|
||||||
|
city: bestHub.city,
|
||||||
|
state: bestHub.state,
|
||||||
|
street: bestHub.street,
|
||||||
|
country: bestHub.country,
|
||||||
|
zipCode: bestHub.zipCode,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
positions: data.positions.map((RapidOrderPosition p) {
|
positions: data.positions.map((RapidOrderPosition p) {
|
||||||
|
final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole(
|
||||||
|
availableRoles,
|
||||||
|
p.role,
|
||||||
|
);
|
||||||
return domain.OneTimeOrderPosition(
|
return domain.OneTimeOrderPosition(
|
||||||
role: p.role,
|
role: matchedRole?.id ?? p.role,
|
||||||
count: p.count,
|
count: p.count,
|
||||||
startTime: startTimeStr,
|
startTime: startTimeStr,
|
||||||
endTime: endTimeStr,
|
endTime: endTimeStr,
|
||||||
@@ -412,8 +468,18 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> transcribeRapidOrder(String audioPath) async {
|
Future<String> transcribeRapidOrder(String audioPath) async {
|
||||||
|
// 1. Upload the audio file first
|
||||||
|
final String fileName = audioPath.split('/').last;
|
||||||
|
final FileUploadResponse uploadResponse = await _fileUploadService
|
||||||
|
.uploadFile(
|
||||||
|
filePath: audioPath,
|
||||||
|
fileName: fileName,
|
||||||
|
category: 'rapid-order-audio',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Transcribe using the remote URI
|
||||||
final RapidOrderTranscriptionResponse response = await _rapidOrderService
|
final RapidOrderTranscriptionResponse response = await _rapidOrderService
|
||||||
.transcribeAudio(audioFileUri: audioPath);
|
.transcribeAudio(audioFileUri: uploadResponse.fileUri);
|
||||||
return response.transcript;
|
return response.transcript;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,4 +709,85 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
}
|
}
|
||||||
return domain.OrderType.oneTime;
|
return domain.OrderType.oneTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub(
|
||||||
|
List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs,
|
||||||
|
String? hint,
|
||||||
|
) {
|
||||||
|
if (hint == null || hint.isEmpty || hubs.isEmpty) return null;
|
||||||
|
final String normalizedHint = hint.toLowerCase();
|
||||||
|
|
||||||
|
dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch;
|
||||||
|
double highestScore = -1;
|
||||||
|
|
||||||
|
for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) {
|
||||||
|
final String name = hub.hubName.toLowerCase();
|
||||||
|
final String address = hub.address.toLowerCase();
|
||||||
|
|
||||||
|
double score = 0;
|
||||||
|
if (name == normalizedHint || address == normalizedHint) {
|
||||||
|
score = 100;
|
||||||
|
} else if (name.contains(normalizedHint) ||
|
||||||
|
address.contains(normalizedHint)) {
|
||||||
|
score = 80;
|
||||||
|
} else if (normalizedHint.contains(name) ||
|
||||||
|
normalizedHint.contains(address)) {
|
||||||
|
score = 60;
|
||||||
|
} else {
|
||||||
|
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
|
||||||
|
final List<String> hubWords = ('$name $address').split(RegExp(r'\s+'));
|
||||||
|
int overlap = 0;
|
||||||
|
for (final String word in hintWords) {
|
||||||
|
if (word.length > 2 && hubWords.contains(word)) overlap++;
|
||||||
|
}
|
||||||
|
score = overlap * 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > highestScore) {
|
||||||
|
highestScore = score;
|
||||||
|
bestMatch = hub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (highestScore >= 10) ? bestMatch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.ListRolesByVendorIdRoles? _findBestRole(
|
||||||
|
List<dc.ListRolesByVendorIdRoles> roles,
|
||||||
|
String? hint,
|
||||||
|
) {
|
||||||
|
if (hint == null || hint.isEmpty || roles.isEmpty) return null;
|
||||||
|
final String normalizedHint = hint.toLowerCase();
|
||||||
|
|
||||||
|
dc.ListRolesByVendorIdRoles? bestMatch;
|
||||||
|
double highestScore = -1;
|
||||||
|
|
||||||
|
for (final dc.ListRolesByVendorIdRoles role in roles) {
|
||||||
|
final String name = role.name.toLowerCase();
|
||||||
|
|
||||||
|
double score = 0;
|
||||||
|
if (name == normalizedHint) {
|
||||||
|
score = 100;
|
||||||
|
} else if (name.contains(normalizedHint)) {
|
||||||
|
score = 80;
|
||||||
|
} else if (normalizedHint.contains(name)) {
|
||||||
|
score = 60;
|
||||||
|
} else {
|
||||||
|
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
|
||||||
|
final List<String> roleWords = name.split(RegExp(r'\s+'));
|
||||||
|
int overlap = 0;
|
||||||
|
for (final String word in hintWords) {
|
||||||
|
if (word.length > 2 && roleWords.contains(word)) overlap++;
|
||||||
|
}
|
||||||
|
score = overlap * 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > highestScore) {
|
||||||
|
highestScore = score;
|
||||||
|
bestMatch = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (highestScore >= 10) ? bestMatch : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
|
|||||||
this._parseRapidOrderUseCase,
|
this._parseRapidOrderUseCase,
|
||||||
this._audioRecorderService,
|
this._audioRecorderService,
|
||||||
) : super(
|
) : super(
|
||||||
const RapidOrderInitial(
|
const RapidOrderState(
|
||||||
examples: <String>[
|
examples: <String>[
|
||||||
'"We had a call out. Need 2 cooks ASAP"',
|
'"We had a call out. Need 2 cooks ASAP"',
|
||||||
'"Need 5 bartenders ASAP until 5am"',
|
'"Need 5 bartenders ASAP until 5am"',
|
||||||
@@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
|
|||||||
RapidOrderMessageChanged event,
|
RapidOrderMessageChanged event,
|
||||||
Emitter<RapidOrderState> emit,
|
Emitter<RapidOrderState> emit,
|
||||||
) {
|
) {
|
||||||
if (state is RapidOrderInitial) {
|
emit(
|
||||||
emit((state as RapidOrderInitial).copyWith(message: event.message));
|
state.copyWith(message: event.message, status: RapidOrderStatus.initial),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onVoiceToggled(
|
Future<void> _onVoiceToggled(
|
||||||
RapidOrderVoiceToggled event,
|
RapidOrderVoiceToggled event,
|
||||||
Emitter<RapidOrderState> emit,
|
Emitter<RapidOrderState> emit,
|
||||||
) async {
|
) async {
|
||||||
if (state is RapidOrderInitial) {
|
if (!state.isListening) {
|
||||||
final RapidOrderInitial currentState = state as RapidOrderInitial;
|
// Start Recording
|
||||||
final bool newListeningState = !currentState.isListening;
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
emit(currentState.copyWith(isListening: newListeningState));
|
action: () async {
|
||||||
|
await _audioRecorderService.startRecording();
|
||||||
// Simulate voice recognition
|
|
||||||
if (newListeningState) {
|
|
||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
|
||||||
if (state is RapidOrderInitial) {
|
|
||||||
emit(
|
emit(
|
||||||
(state as RapidOrderInitial).copyWith(
|
state.copyWith(isListening: true, status: RapidOrderStatus.initial),
|
||||||
message: 'Need 2 servers for a banquet right now.',
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) =>
|
||||||
|
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Stop Recording and Transcribe
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
// 1. Stop recording
|
||||||
|
final String? audioPath = await _audioRecorderService.stopRecording();
|
||||||
|
|
||||||
|
if (audioPath == null) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
isListening: false,
|
isListening: false,
|
||||||
|
status: RapidOrderStatus.initial,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// 2. Transcribe
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isListening: false,
|
||||||
|
isTranscribing: true,
|
||||||
|
status: RapidOrderStatus.initial,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final String transcription = await _transcribeRapidOrderUseCase(
|
||||||
|
audioPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update message
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
message: transcription,
|
||||||
|
isListening: false,
|
||||||
|
isTranscribing: false,
|
||||||
|
status: RapidOrderStatus.initial,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
status: RapidOrderStatus.failure,
|
||||||
|
error: errorKey,
|
||||||
|
isListening: false,
|
||||||
|
isTranscribing: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,30 +113,29 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
|
|||||||
RapidOrderSubmitted event,
|
RapidOrderSubmitted event,
|
||||||
Emitter<RapidOrderState> emit,
|
Emitter<RapidOrderState> emit,
|
||||||
) async {
|
) async {
|
||||||
final RapidOrderState currentState = state;
|
final String message = state.message;
|
||||||
if (currentState is RapidOrderInitial) {
|
emit(state.copyWith(status: RapidOrderStatus.submitting));
|
||||||
final String message = currentState.message;
|
|
||||||
emit(const RapidOrderSubmitting());
|
|
||||||
|
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
|
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
|
||||||
emit(RapidOrderParsed(order));
|
emit(
|
||||||
},
|
state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
|
||||||
onError: (String errorKey) => RapidOrderFailure(errorKey),
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) =>
|
||||||
|
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onExampleSelected(
|
void _onExampleSelected(
|
||||||
RapidOrderExampleSelected event,
|
RapidOrderExampleSelected event,
|
||||||
Emitter<RapidOrderState> emit,
|
Emitter<RapidOrderState> emit,
|
||||||
) {
|
) {
|
||||||
if (state is RapidOrderInitial) {
|
|
||||||
final String cleanedExample = event.example.replaceAll('"', '');
|
final String cleanedExample = event.example.replaceAll('"', '');
|
||||||
emit((state as RapidOrderInitial).copyWith(message: cleanedExample));
|
emit(
|
||||||
}
|
state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,55 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
abstract class RapidOrderState extends Equatable {
|
enum RapidOrderStatus { initial, submitting, parsed, failure }
|
||||||
const RapidOrderState();
|
|
||||||
|
|
||||||
@override
|
class RapidOrderState extends Equatable {
|
||||||
List<Object?> get props => <Object?>[];
|
const RapidOrderState({
|
||||||
}
|
this.status = RapidOrderStatus.initial,
|
||||||
|
|
||||||
class RapidOrderInitial extends RapidOrderState {
|
|
||||||
const RapidOrderInitial({
|
|
||||||
this.message = '',
|
this.message = '',
|
||||||
this.isListening = false,
|
this.isListening = false,
|
||||||
required this.examples,
|
this.isTranscribing = false,
|
||||||
|
this.examples = const <String>[],
|
||||||
|
this.error,
|
||||||
|
this.parsedOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final RapidOrderStatus status;
|
||||||
final String message;
|
final String message;
|
||||||
final bool isListening;
|
final bool isListening;
|
||||||
|
final bool isTranscribing;
|
||||||
final List<String> examples;
|
final List<String> examples;
|
||||||
|
final String? error;
|
||||||
|
final OneTimeOrder? parsedOrder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[message, isListening, examples];
|
List<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
isListening,
|
||||||
|
isTranscribing,
|
||||||
|
examples,
|
||||||
|
error,
|
||||||
|
parsedOrder,
|
||||||
|
];
|
||||||
|
|
||||||
RapidOrderInitial copyWith({
|
RapidOrderState copyWith({
|
||||||
|
RapidOrderStatus? status,
|
||||||
String? message,
|
String? message,
|
||||||
bool? isListening,
|
bool? isListening,
|
||||||
|
bool? isTranscribing,
|
||||||
List<String>? examples,
|
List<String>? examples,
|
||||||
|
String? error,
|
||||||
|
OneTimeOrder? parsedOrder,
|
||||||
}) {
|
}) {
|
||||||
return RapidOrderInitial(
|
return RapidOrderState(
|
||||||
|
status: status ?? this.status,
|
||||||
message: message ?? this.message,
|
message: message ?? this.message,
|
||||||
isListening: isListening ?? this.isListening,
|
isListening: isListening ?? this.isListening,
|
||||||
|
isTranscribing: isTranscribing ?? this.isTranscribing,
|
||||||
examples: examples ?? this.examples,
|
examples: examples ?? this.examples,
|
||||||
|
error: error ?? this.error,
|
||||||
|
parsedOrder: parsedOrder ?? this.parsedOrder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RapidOrderSubmitting extends RapidOrderState {
|
|
||||||
const RapidOrderSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
class RapidOrderSuccess extends RapidOrderState {
|
|
||||||
const RapidOrderSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
class RapidOrderFailure extends RapidOrderState {
|
|
||||||
const RapidOrderFailure(this.error);
|
|
||||||
final String error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[error];
|
|
||||||
}
|
|
||||||
|
|
||||||
class RapidOrderParsed extends RapidOrderState {
|
|
||||||
const RapidOrderParsed(this.order);
|
|
||||||
final OneTimeOrder order;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[order];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ class OneTimeOrderPage extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
hubManagers: state.managers.map(_mapManager).toList(),
|
hubManagers: state.managers.map(_mapManager).toList(),
|
||||||
isValid: state.isValid,
|
isValid: state.isValid,
|
||||||
title: state.isRapidDraft ? 'Rapid Order : Verify the order' : null,
|
title: state.isRapidDraft ? 'Rapid Order' : null,
|
||||||
|
subtitle: state.isRapidDraft ? 'Verify the order details' : null,
|
||||||
onEventNameChanged: (String val) =>
|
onEventNameChanged: (String val) =>
|
||||||
bloc.add(OneTimeOrderEventNameChanged(val)),
|
bloc.add(OneTimeOrderEventNameChanged(val)),
|
||||||
onVendorChanged: (Vendor val) =>
|
onVendorChanged: (Vendor val) =>
|
||||||
|
|||||||
@@ -12,18 +12,16 @@ class UiOrderType {
|
|||||||
|
|
||||||
/// Order type constants for the create order feature
|
/// Order type constants for the create order feature
|
||||||
const List<UiOrderType> orderTypes = <UiOrderType>[
|
const List<UiOrderType> orderTypes = <UiOrderType>[
|
||||||
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
|
UiOrderType(
|
||||||
// UiOrderType(
|
id: 'rapid',
|
||||||
// id: 'rapid',
|
titleKey: 'client_create_order.types.rapid',
|
||||||
// titleKey: 'client_create_order.types.rapid',
|
descriptionKey: 'client_create_order.types.rapid_desc',
|
||||||
// descriptionKey: 'client_create_order.types.rapid_desc',
|
),
|
||||||
// ),
|
|
||||||
UiOrderType(
|
UiOrderType(
|
||||||
id: 'one-time',
|
id: 'one-time',
|
||||||
titleKey: 'client_create_order.types.one_time',
|
titleKey: 'client_create_order.types.one_time',
|
||||||
descriptionKey: 'client_create_order.types.one_time_desc',
|
descriptionKey: 'client_create_order.types.one_time_desc',
|
||||||
),
|
),
|
||||||
|
|
||||||
UiOrderType(
|
UiOrderType(
|
||||||
id: 'recurring',
|
id: 'recurring',
|
||||||
titleKey: 'client_create_order.types.recurring',
|
titleKey: 'client_create_order.types.recurring',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import '../../blocs/rapid_order/rapid_order_event.dart';
|
|||||||
import '../../blocs/rapid_order/rapid_order_state.dart';
|
import '../../blocs/rapid_order/rapid_order_state.dart';
|
||||||
import 'rapid_order_example_card.dart';
|
import 'rapid_order_example_card.dart';
|
||||||
import 'rapid_order_header.dart';
|
import 'rapid_order_header.dart';
|
||||||
import 'rapid_order_success_view.dart';
|
|
||||||
|
|
||||||
/// The main content of the Rapid Order page.
|
/// The main content of the Rapid Order page.
|
||||||
class RapidOrderView extends StatelessWidget {
|
class RapidOrderView extends StatelessWidget {
|
||||||
@@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsClientCreateOrderRapidEn labels =
|
|
||||||
t.client_create_order.rapid;
|
|
||||||
|
|
||||||
return BlocBuilder<RapidOrderBloc, RapidOrderState>(
|
|
||||||
builder: (BuildContext context, RapidOrderState state) {
|
|
||||||
if (state is RapidOrderSuccess) {
|
|
||||||
return RapidOrderSuccessView(
|
|
||||||
title: labels.success_title,
|
|
||||||
message: labels.success_message,
|
|
||||||
buttonLabel: labels.back_to_orders,
|
|
||||||
onDone: () => Modular.to.toClientOrders(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const _RapidOrderForm();
|
return const _RapidOrderForm();
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,24 +48,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
|
|
||||||
return BlocListener<RapidOrderBloc, RapidOrderState>(
|
return BlocListener<RapidOrderBloc, RapidOrderState>(
|
||||||
listener: (BuildContext context, RapidOrderState state) {
|
listener: (BuildContext context, RapidOrderState state) {
|
||||||
if (state is RapidOrderInitial) {
|
if (state.status == RapidOrderStatus.initial) {
|
||||||
if (_messageController.text != state.message) {
|
if (_messageController.text != state.message) {
|
||||||
_messageController.text = state.message;
|
_messageController.text = state.message;
|
||||||
_messageController.selection = TextSelection.fromPosition(
|
_messageController.selection = TextSelection.fromPosition(
|
||||||
TextPosition(offset: _messageController.text.length),
|
TextPosition(offset: _messageController.text.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (state is RapidOrderParsed) {
|
} else if (state.status == RapidOrderStatus.parsed &&
|
||||||
|
state.parsedOrder != null) {
|
||||||
Modular.to.toCreateOrderOneTime(
|
Modular.to.toCreateOrderOneTime(
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'order': state.order,
|
'order': state.parsedOrder,
|
||||||
'isRapidDraft': true,
|
'isRapidDraft': true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (state is RapidOrderFailure) {
|
} else if (state.status == RapidOrderStatus.failure &&
|
||||||
|
state.error != null) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: translateErrorKey(state.error),
|
message: translateErrorKey(state.error!),
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,68 +80,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
subtitle: labels.subtitle,
|
subtitle: labels.subtitle,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
|
onBack: () => Modular.to.toCreateOrder(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
|
||||||
|
builder: (BuildContext context, RapidOrderState state) {
|
||||||
|
final bool isSubmitting =
|
||||||
|
state.status == RapidOrderStatus.submitting;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
labels.tell_us,
|
|
||||||
style: UiTypography.headline3m.textPrimary,
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space2,
|
|
||||||
vertical: UiConstants.space1,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.destructive,
|
|
||||||
borderRadius: UiConstants.radiusSm,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
labels.urgent_badge,
|
|
||||||
style: UiTypography.footnote2b.copyWith(
|
|
||||||
color: UiColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
|
|
||||||
// Main Card
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white,
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
|
|
||||||
builder: (BuildContext context, RapidOrderState state) {
|
|
||||||
final RapidOrderInitial? initialState =
|
|
||||||
state is RapidOrderInitial ? state : null;
|
|
||||||
final bool isSubmitting =
|
|
||||||
state is RapidOrderSubmitting;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Icon
|
// Icon
|
||||||
const _AnimatedZapIcon(),
|
const _AnimatedZapIcon(),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Text(
|
Text(
|
||||||
labels.need_staff,
|
labels.need_staff,
|
||||||
style: UiTypography.headline2m.textPrimary,
|
style: UiTypography.headline3b.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
Text(
|
Text(
|
||||||
labels.type_or_speak,
|
labels.type_or_speak,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -165,8 +112,7 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
// Examples
|
// Examples
|
||||||
if (initialState != null)
|
...state.examples.asMap().entries.map((
|
||||||
...initialState.examples.asMap().entries.map((
|
|
||||||
MapEntry<int, String> entry,
|
MapEntry<int, String> entry,
|
||||||
) {
|
) {
|
||||||
final int index = entry.key;
|
final int index = entry.key;
|
||||||
@@ -203,16 +149,19 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
},
|
},
|
||||||
hintText: labels.hint,
|
hintText: labels.hint,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
],
|
||||||
|
),
|
||||||
// Actions
|
),
|
||||||
_RapidOrderActions(
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: _RapidOrderActions(
|
||||||
labels: labels,
|
labels: labels,
|
||||||
isSubmitting: isSubmitting,
|
isSubmitting: isSubmitting,
|
||||||
isListening: initialState?.isListening ?? false,
|
isListening: state.isListening,
|
||||||
isMessageEmpty:
|
isTranscribing: state.isTranscribing,
|
||||||
initialState != null &&
|
isMessageEmpty: state.message.trim().isEmpty,
|
||||||
initialState.message.trim().isEmpty,
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -222,10 +171,6 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
boxShadow: <BoxShadow>[
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.destructive.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: const Icon(UiIcons.zap, color: UiColors.white, size: 32),
|
child: const Icon(UiIcons.zap, color: UiColors.white, size: 32),
|
||||||
);
|
);
|
||||||
@@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget {
|
|||||||
required this.labels,
|
required this.labels,
|
||||||
required this.isSubmitting,
|
required this.isSubmitting,
|
||||||
required this.isListening,
|
required this.isListening,
|
||||||
|
required this.isTranscribing,
|
||||||
required this.isMessageEmpty,
|
required this.isMessageEmpty,
|
||||||
});
|
});
|
||||||
final TranslationsClientCreateOrderRapidEn labels;
|
final TranslationsClientCreateOrderRapidEn labels;
|
||||||
final bool isSubmitting;
|
final bool isSubmitting;
|
||||||
final bool isListening;
|
final bool isListening;
|
||||||
|
final bool isTranscribing;
|
||||||
final bool isMessageEmpty;
|
final bool isMessageEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -279,9 +219,15 @@ class _RapidOrderActions extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UiButton.secondary(
|
child: UiButton.secondary(
|
||||||
text: isListening ? labels.listening : labels.speak,
|
text: isTranscribing
|
||||||
|
? labels.transcribing
|
||||||
|
: isListening
|
||||||
|
? labels.listening
|
||||||
|
: labels.speak,
|
||||||
leadingIcon: UiIcons.microphone,
|
leadingIcon: UiIcons.microphone,
|
||||||
onPressed: () => BlocProvider.of<RapidOrderBloc>(
|
onPressed: isTranscribing
|
||||||
|
? null
|
||||||
|
: () => BlocProvider.of<RapidOrderBloc>(
|
||||||
context,
|
context,
|
||||||
).add(const RapidOrderVoiceToggled()),
|
).add(const RapidOrderVoiceToggled()),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class OneTimeOrderView extends StatelessWidget {
|
|||||||
required this.onDone,
|
required this.onDone,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.subtitle,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class OneTimeOrderView extends StatelessWidget {
|
|||||||
final OrderManagerUiModel? selectedHubManager;
|
final OrderManagerUiModel? selectedHubManager;
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
final ValueChanged<String> onEventNameChanged;
|
final ValueChanged<String> onEventNameChanged;
|
||||||
final ValueChanged<Vendor> onVendorChanged;
|
final ValueChanged<Vendor> onVendorChanged;
|
||||||
@@ -102,7 +104,7 @@ class OneTimeOrderView extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
OneTimeOrderHeader(
|
OneTimeOrderHeader(
|
||||||
title: title ?? labels.title,
|
title: title ?? labels.title,
|
||||||
subtitle: labels.subtitle,
|
subtitle: subtitle ?? labels.subtitle,
|
||||||
onBack: onBack,
|
onBack: onBack,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -140,7 +142,7 @@ class OneTimeOrderView extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
OneTimeOrderHeader(
|
OneTimeOrderHeader(
|
||||||
title: title ?? labels.title,
|
title: title ?? labels.title,
|
||||||
subtitle: labels.subtitle,
|
subtitle: subtitle ?? labels.subtitle,
|
||||||
onBack: onBack,
|
onBack: onBack,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import 'package:core_localization/core_localization.dart';
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
/// A quick report card widget for navigating to specific reports.
|
/// A quick report card widget for navigating to specific reports.
|
||||||
///
|
///
|
||||||
/// Displays an icon, name, and a quick navigation to a report page.
|
/// Displays an icon, name, and a quick navigation to a report page.
|
||||||
/// Used in the quick reports grid of the reports page.
|
/// Used in the quick reports grid of the reports page.
|
||||||
class ReportCard extends StatelessWidget {
|
class ReportCard extends StatelessWidget {
|
||||||
|
|
||||||
const ReportCard({
|
const ReportCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -17,6 +17,7 @@ class ReportCard extends StatelessWidget {
|
|||||||
required this.iconColor,
|
required this.iconColor,
|
||||||
required this.route,
|
required this.route,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The icon to display for this report.
|
/// The icon to display for this report.
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class ReportCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Modular.to.pushNamed(route),
|
onTap: () => Modular.to.safePush(route),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -86,8 +87,7 @@ class ReportCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
context.t.client_reports.quick_reports
|
context.t.client_reports.quick_reports.two_click_export,
|
||||||
.two_click_export,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import '../widgets/phone_verification_page/phone_input.dart';
|
|||||||
/// This page coordinates the authentication flow by switching between
|
/// This page coordinates the authentication flow by switching between
|
||||||
/// [PhoneInput] and [OtpVerification] based on the current [AuthState].
|
/// [PhoneInput] and [OtpVerification] based on the current [AuthState].
|
||||||
class PhoneVerificationPage extends StatefulWidget {
|
class PhoneVerificationPage extends StatefulWidget {
|
||||||
|
|
||||||
/// Creates a [PhoneVerificationPage].
|
/// Creates a [PhoneVerificationPage].
|
||||||
const PhoneVerificationPage({super.key, required this.mode});
|
const PhoneVerificationPage({super.key, required this.mode});
|
||||||
|
|
||||||
/// The authentication mode (login or signup).
|
/// The authentication mode (login or signup).
|
||||||
final AuthMode mode;
|
final AuthMode mode;
|
||||||
|
|
||||||
@@ -123,10 +123,10 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
);
|
);
|
||||||
Future<void>.delayed(const Duration(seconds: 5), () {
|
Future<void>.delayed(const Duration(seconds: 5), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Modular.to.navigate('/');
|
Modular.to.toInitialPage();
|
||||||
});
|
});
|
||||||
} else if (messageKey == 'errors.auth.unauthorized_app') {
|
} else if (messageKey == 'errors.auth.unauthorized_app') {
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in;
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff.clock_in;
|
||||||
return BlocProvider<ClockInBloc>.value(
|
return BlocProvider<ClockInBloc>.value(
|
||||||
value: _bloc,
|
value: _bloc,
|
||||||
child: BlocConsumer<ClockInBloc, ClockInState>(
|
child: BlocConsumer<ClockInBloc, ClockInState>(
|
||||||
@@ -60,22 +62,17 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
final String? activeShiftId = state.attendance.activeShiftId;
|
final String? activeShiftId = state.attendance.activeShiftId;
|
||||||
final bool isActiveSelected =
|
final bool isActiveSelected =
|
||||||
selectedShift != null && selectedShift.id == activeShiftId;
|
selectedShift != null && selectedShift.id == activeShiftId;
|
||||||
final DateTime? checkInTime =
|
final DateTime? checkInTime = isActiveSelected
|
||||||
isActiveSelected ? state.attendance.checkInTime : null;
|
? state.attendance.checkInTime
|
||||||
final DateTime? checkOutTime =
|
: null;
|
||||||
isActiveSelected ? state.attendance.checkOutTime : null;
|
final DateTime? checkOutTime = isActiveSelected
|
||||||
|
? state.attendance.checkOutTime
|
||||||
|
: null;
|
||||||
final bool isCheckedIn =
|
final bool isCheckedIn =
|
||||||
state.attendance.isCheckedIn && isActiveSelected;
|
state.attendance.isCheckedIn && isActiveSelected;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: UiAppBar(
|
appBar: UiAppBar(title: i18n.title, showBackButton: false),
|
||||||
titleWidget: Text(
|
|
||||||
i18n.title,
|
|
||||||
style: UiTypography.title1m.textPrimary,
|
|
||||||
),
|
|
||||||
showBackButton: false,
|
|
||||||
centerTitle: false,
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
@@ -92,18 +89,18 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Commute Tracker (shows before date selector when applicable)
|
// // Commute Tracker (shows before date selector when applicable)
|
||||||
if (selectedShift != null)
|
// if (selectedShift != null)
|
||||||
CommuteTracker(
|
// CommuteTracker(
|
||||||
shift: selectedShift,
|
// shift: selectedShift,
|
||||||
hasLocationConsent: state.hasLocationConsent,
|
// hasLocationConsent: state.hasLocationConsent,
|
||||||
isCommuteModeOn: state.isCommuteModeOn,
|
// isCommuteModeOn: state.isCommuteModeOn,
|
||||||
distanceMeters: state.distanceFromVenue,
|
// distanceMeters: state.distanceFromVenue,
|
||||||
etaMinutes: state.etaMinutes,
|
// etaMinutes: state.etaMinutes,
|
||||||
onCommuteToggled: (bool value) {
|
// onCommuteToggled: (bool value) {
|
||||||
_bloc.add(CommuteModeToggled(value));
|
// _bloc.add(CommuteModeToggled(value));
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
// Date Selector
|
// Date Selector
|
||||||
DateSelector(
|
DateSelector(
|
||||||
selectedDate: state.selectedDate,
|
selectedDate: state.selectedDate,
|
||||||
@@ -141,15 +138,12 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius:
|
borderRadius: UiConstants.radiusLg,
|
||||||
UiConstants.radiusLg,
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: shift.id ==
|
color: shift.id == selectedShift?.id
|
||||||
selectedShift?.id
|
|
||||||
? UiColors.primary
|
? UiColors.primary
|
||||||
: UiColors.border,
|
: UiColors.border,
|
||||||
width:
|
width: shift.id == selectedShift?.id
|
||||||
shift.id == selectedShift?.id
|
|
||||||
? 2
|
? 2
|
||||||
: 1,
|
: 1,
|
||||||
),
|
),
|
||||||
@@ -166,15 +160,15 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
Text(
|
Text(
|
||||||
shift.id ==
|
shift.id ==
|
||||||
selectedShift?.id
|
selectedShift?.id
|
||||||
? i18n
|
? i18n.selected_shift_badge
|
||||||
.selected_shift_badge
|
: i18n.today_shift_badge,
|
||||||
: i18n
|
|
||||||
.today_shift_badge,
|
|
||||||
style: UiTypography
|
style: UiTypography
|
||||||
.titleUppercase4b
|
.titleUppercase4b
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: shift.id ==
|
color:
|
||||||
selectedShift?.id
|
shift.id ==
|
||||||
|
selectedShift
|
||||||
|
?.id
|
||||||
? UiColors.primary
|
? UiColors.primary
|
||||||
: UiColors
|
: UiColors
|
||||||
.textSecondary,
|
.textSecondary,
|
||||||
@@ -187,7 +181,8 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"${shift.clientName} • ${shift.location}",
|
"${shift.clientName} • ${shift.location}",
|
||||||
style: UiTypography.body3r
|
style: UiTypography
|
||||||
|
.body3r
|
||||||
.textSecondary,
|
.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -199,7 +194,8 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
|
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
|
||||||
style: UiTypography.body3m
|
style: UiTypography
|
||||||
|
.body3m
|
||||||
.textSecondary,
|
.textSecondary,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -226,8 +222,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
!_isCheckInAllowed(selectedShift))
|
!_isCheckInAllowed(selectedShift))
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding:
|
padding: const EdgeInsets.all(
|
||||||
const EdgeInsets.all(UiConstants.space6),
|
UiConstants.space6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.bgSecondary,
|
color: UiColors.bgSecondary,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
@@ -259,81 +256,109 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
)
|
)
|
||||||
else ...<Widget>[
|
else ...<Widget>[
|
||||||
// Attire Photo Section
|
// Attire Photo Section
|
||||||
if (!isCheckedIn) ...<Widget>[
|
// if (!isCheckedIn) ...<Widget>[
|
||||||
Container(
|
// Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
// padding: const EdgeInsets.all(
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
// UiConstants.space4,
|
||||||
decoration: BoxDecoration(
|
// ),
|
||||||
color: UiColors.white,
|
// margin: const EdgeInsets.only(
|
||||||
borderRadius: UiConstants.radiusLg,
|
// bottom: UiConstants.space4,
|
||||||
border: Border.all(color: UiColors.border),
|
// ),
|
||||||
),
|
// decoration: BoxDecoration(
|
||||||
child: Row(
|
// color: UiColors.white,
|
||||||
children: <Widget>[
|
// borderRadius: UiConstants.radiusLg,
|
||||||
Container(
|
// border: Border.all(color: UiColors.border),
|
||||||
width: 48,
|
// ),
|
||||||
height: 48,
|
// child: Row(
|
||||||
decoration: BoxDecoration(
|
// children: <Widget>[
|
||||||
color: UiColors.bgSecondary,
|
// Container(
|
||||||
borderRadius: UiConstants.radiusMd,
|
// width: 48,
|
||||||
),
|
// height: 48,
|
||||||
child: const Icon(UiIcons.camera, color: UiColors.primary),
|
// decoration: BoxDecoration(
|
||||||
),
|
// color: UiColors.bgSecondary,
|
||||||
const SizedBox(width: UiConstants.space3),
|
// borderRadius: UiConstants.radiusMd,
|
||||||
Expanded(
|
// ),
|
||||||
child: Column(
|
// child: const Icon(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// UiIcons.camera,
|
||||||
children: <Widget>[
|
// color: UiColors.primary,
|
||||||
Text(i18n.attire_photo_label, style: UiTypography.body2b),
|
// ),
|
||||||
Text(i18n.attire_photo_desc, style: UiTypography.body3r.textSecondary),
|
// ),
|
||||||
],
|
// const SizedBox(width: UiConstants.space3),
|
||||||
),
|
// Expanded(
|
||||||
),
|
// child: Column(
|
||||||
UiButton.secondary(
|
// crossAxisAlignment:
|
||||||
text: i18n.take_attire_photo,
|
// CrossAxisAlignment.start,
|
||||||
onPressed: () {
|
// children: <Widget>[
|
||||||
UiSnackbar.show(
|
// Text(
|
||||||
context,
|
// i18n.attire_photo_label,
|
||||||
message: i18n.attire_captured,
|
// style: UiTypography.body2b,
|
||||||
type: UiSnackbarType.success,
|
// ),
|
||||||
);
|
// Text(
|
||||||
},
|
// i18n.attire_photo_desc,
|
||||||
),
|
// style: UiTypography
|
||||||
],
|
// .body3r
|
||||||
),
|
// .textSecondary,
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
|
// ),
|
||||||
if (!isCheckedIn && (!state.isLocationVerified || state.currentLocation == null)) ...<Widget>[
|
// ),
|
||||||
Container(
|
// UiButton.secondary(
|
||||||
width: double.infinity,
|
// text: i18n.take_attire_photo,
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
// onPressed: () {
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
// UiSnackbar.show(
|
||||||
decoration: BoxDecoration(
|
// context,
|
||||||
color: UiColors.tagError,
|
// message: i18n.attire_captured,
|
||||||
borderRadius: UiConstants.radiusLg,
|
// type: UiSnackbarType.success,
|
||||||
),
|
// );
|
||||||
child: Row(
|
// },
|
||||||
children: [
|
// ),
|
||||||
const Icon(UiIcons.error, color: UiColors.textError, size: 20),
|
// ],
|
||||||
const SizedBox(width: UiConstants.space3),
|
// ),
|
||||||
Expanded(
|
// ),
|
||||||
child: Text(
|
// ],
|
||||||
state.currentLocation == null
|
|
||||||
? i18n.location_verifying
|
|
||||||
: i18n.not_in_range(distance: '500'),
|
|
||||||
style: UiTypography.body3m.textError,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
|
// if (!isCheckedIn &&
|
||||||
|
// (!state.isLocationVerified ||
|
||||||
|
// state.currentLocation ==
|
||||||
|
// null)) ...<Widget>[
|
||||||
|
// Container(
|
||||||
|
// width: double.infinity,
|
||||||
|
// padding: const EdgeInsets.all(
|
||||||
|
// UiConstants.space4,
|
||||||
|
// ),
|
||||||
|
// margin: const EdgeInsets.only(
|
||||||
|
// bottom: UiConstants.space4,
|
||||||
|
// ),
|
||||||
|
// decoration: BoxDecoration(
|
||||||
|
// color: UiColors.tagError,
|
||||||
|
// borderRadius: UiConstants.radiusLg,
|
||||||
|
// ),
|
||||||
|
// child: Row(
|
||||||
|
// children: [
|
||||||
|
// const Icon(
|
||||||
|
// UiIcons.error,
|
||||||
|
// color: UiColors.textError,
|
||||||
|
// size: 20,
|
||||||
|
// ),
|
||||||
|
// const SizedBox(width: UiConstants.space3),
|
||||||
|
// Expanded(
|
||||||
|
// child: Text(
|
||||||
|
// state.currentLocation == null
|
||||||
|
// ? i18n.location_verifying
|
||||||
|
// : i18n.not_in_range(
|
||||||
|
// distance: '500',
|
||||||
|
// ),
|
||||||
|
// style: UiTypography.body3m.textError,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
SwipeToCheckIn(
|
SwipeToCheckIn(
|
||||||
isCheckedIn: isCheckedIn,
|
isCheckedIn: isCheckedIn,
|
||||||
mode: state.checkInMode,
|
mode: state.checkInMode,
|
||||||
isDisabled: !isCheckedIn && !state.isLocationVerified,
|
isDisabled: isCheckedIn,
|
||||||
isLoading:
|
isLoading:
|
||||||
state.status ==
|
state.status ==
|
||||||
ClockInStatus.actionInProgress,
|
ClockInStatus.actionInProgress,
|
||||||
@@ -554,7 +579,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showNFCDialog(BuildContext context) async {
|
Future<void> _showNFCDialog(BuildContext context) async {
|
||||||
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in;
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff.clock_in;
|
||||||
bool scanned = false;
|
bool scanned = false;
|
||||||
|
|
||||||
// Using a local navigator context since we are in a dialog
|
// Using a local navigator context since we are in a dialog
|
||||||
@@ -668,7 +695,13 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
try {
|
try {
|
||||||
final List<String> parts = timeStr.split(':');
|
final List<String> parts = timeStr.split(':');
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final DateTime dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1]));
|
final DateTime dt = DateTime(
|
||||||
|
2022,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
int.parse(parts[0]),
|
||||||
|
int.parse(parts[1]),
|
||||||
|
);
|
||||||
return DateFormat('h:mm a').format(dt);
|
return DateFormat('h:mm a').format(dt);
|
||||||
}
|
}
|
||||||
return timeStr;
|
return timeStr;
|
||||||
@@ -683,7 +716,9 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
// Parse shift date (e.g. 2024-01-31T09:00:00)
|
// Parse shift date (e.g. 2024-01-31T09:00:00)
|
||||||
// The Shift entity has 'date' which is the start DateTime string
|
// The Shift entity has 'date' which is the start DateTime string
|
||||||
final DateTime shiftStart = DateTime.parse(shift.startTime);
|
final DateTime shiftStart = DateTime.parse(shift.startTime);
|
||||||
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15));
|
final DateTime windowStart = shiftStart.subtract(
|
||||||
|
const Duration(minutes: 15),
|
||||||
|
);
|
||||||
return DateTime.now().isAfter(windowStart);
|
return DateTime.now().isAfter(windowStart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback: If parsing fails, allow check in to avoid blocking.
|
// Fallback: If parsing fails, allow check in to avoid blocking.
|
||||||
@@ -694,13 +729,15 @@ class _ClockInPageState extends State<ClockInPage> {
|
|||||||
String _getCheckInAvailabilityTime(Shift shift) {
|
String _getCheckInAvailabilityTime(Shift shift) {
|
||||||
try {
|
try {
|
||||||
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
|
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
|
||||||
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15));
|
final DateTime windowStart = shiftStart.subtract(
|
||||||
|
const Duration(minutes: 15),
|
||||||
|
);
|
||||||
return DateFormat('h:mm a').format(windowStart);
|
return DateFormat('h:mm a').format(windowStart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in;
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff.clock_in;
|
||||||
return i18n.soon;
|
return i18n.soon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
|
|
||||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||||
|
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
|
||||||
|
|
||||||
part 'home_state.dart';
|
part 'home_state.dart';
|
||||||
|
|
||||||
@@ -14,18 +13,18 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
|||||||
final GetHomeShifts _getHomeShifts;
|
final GetHomeShifts _getHomeShifts;
|
||||||
final HomeRepository _repository;
|
final HomeRepository _repository;
|
||||||
|
|
||||||
/// Use case that checks whether the staff member's personal info is complete.
|
/// Use case that checks whether the staff member's profile is complete.
|
||||||
///
|
///
|
||||||
/// Used to determine whether profile-gated features (such as shift browsing)
|
/// Used to determine whether profile-gated features (such as shift browsing)
|
||||||
/// should be enabled on the home screen.
|
/// should be enabled on the home screen.
|
||||||
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletion;
|
final GetProfileCompletionUseCase _getProfileCompletion;
|
||||||
|
|
||||||
HomeCubit({
|
HomeCubit({
|
||||||
required HomeRepository repository,
|
required HomeRepository repository,
|
||||||
required GetPersonalInfoCompletionUseCase getPersonalInfoCompletion,
|
required GetProfileCompletionUseCase getProfileCompletion,
|
||||||
}) : _getHomeShifts = GetHomeShifts(repository),
|
}) : _getHomeShifts = GetHomeShifts(repository),
|
||||||
_repository = repository,
|
_repository = repository,
|
||||||
_getPersonalInfoCompletion = getPersonalInfoCompletion,
|
_getProfileCompletion = getProfileCompletion,
|
||||||
super(const HomeState.initial());
|
super(const HomeState.initial());
|
||||||
|
|
||||||
Future<void> loadShifts() async {
|
Future<void> loadShifts() async {
|
||||||
@@ -37,7 +36,7 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
|||||||
// Fetch shifts, name, benefits and profile completion status concurrently
|
// Fetch shifts, name, benefits and profile completion status concurrently
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_getHomeShifts.call(),
|
_getHomeShifts.call(),
|
||||||
_getPersonalInfoCompletion.call(),
|
_getProfileCompletion.call(),
|
||||||
_repository.getBenefits(),
|
_repository.getBenefits(),
|
||||||
_repository.getStaffName(),
|
_repository.getStaffName(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -61,12 +61,17 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
horizontal: UiConstants.space4,
|
horizontal: UiConstants.space4,
|
||||||
vertical: UiConstants.space4,
|
vertical: UiConstants.space4,
|
||||||
),
|
),
|
||||||
|
child: BlocBuilder<HomeCubit, HomeState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.isProfileComplete != current.isProfileComplete,
|
||||||
|
builder: (context, state) {
|
||||||
|
if (!state.isProfileComplete) {
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height -
|
||||||
|
300,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
BlocBuilder<HomeCubit, HomeState>(
|
PlaceholderBanner(
|
||||||
builder: (context, state) {
|
|
||||||
if (state.isProfileComplete) return const SizedBox();
|
|
||||||
return PlaceholderBanner(
|
|
||||||
title: bannersI18n.complete_profile_title,
|
title: bannersI18n.complete_profile_title,
|
||||||
subtitle: bannersI18n.complete_profile_subtitle,
|
subtitle: bannersI18n.complete_profile_subtitle,
|
||||||
bg: UiColors.primaryInverse,
|
bg: UiColors.primaryInverse,
|
||||||
@@ -74,12 +79,22 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Modular.to.toProfile();
|
Modular.to.toProfile();
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UiConstants.space10),
|
||||||
|
Expanded(
|
||||||
|
child: UiEmptyState(
|
||||||
|
icon: UiIcons.users,
|
||||||
|
title: 'Complete Your Profile',
|
||||||
|
description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
return Column(
|
||||||
|
children: [
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -224,6 +239,8 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ class StaffHomeModule extends Module {
|
|||||||
() => StaffConnectorRepositoryImpl(),
|
() => StaffConnectorRepositoryImpl(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use case for checking personal info profile completion
|
// Use case for checking profile completion
|
||||||
i.addLazySingleton<GetPersonalInfoCompletionUseCase>(
|
i.addLazySingleton<GetProfileCompletionUseCase>(
|
||||||
() => GetPersonalInfoCompletionUseCase(
|
() => GetProfileCompletionUseCase(
|
||||||
repository: i.get<StaffConnectorRepository>(),
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -35,7 +35,7 @@ class StaffHomeModule extends Module {
|
|||||||
i.addSingleton(
|
i.addSingleton(
|
||||||
() => HomeCubit(
|
() => HomeCubit(
|
||||||
repository: i.get<HomeRepository>(),
|
repository: i.get<HomeRepository>(),
|
||||||
getPersonalInfoCompletion: i.get<GetPersonalInfoCompletionUseCase>(),
|
getProfileCompletion: i.get<GetProfileCompletionUseCase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
|
|||||||
String? certificateNumber,
|
String? certificateNumber,
|
||||||
}) async {
|
}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
|
// Get existing certificate to check if file has changed
|
||||||
|
final List<domain.StaffCertificate> existingCerts = await getCertificates();
|
||||||
|
domain.StaffCertificate? existingCert;
|
||||||
|
try {
|
||||||
|
existingCert = existingCerts.firstWhere(
|
||||||
|
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Certificate doesn't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
String? signedUrl = existingCert?.certificateUrl;
|
||||||
|
String? verificationId = existingCert?.verificationId;
|
||||||
|
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
|
||||||
|
|
||||||
|
// Only upload and verify if file path has changed
|
||||||
|
if (fileChanged) {
|
||||||
// 1. Upload the file to cloud storage
|
// 1. Upload the file to cloud storage
|
||||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
@@ -44,41 +61,37 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. Generate a signed URL for verification service to access the file
|
// 2. Generate a signed URL for verification service to access the file
|
||||||
// Wait, verification service might need this or just the URI.
|
final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||||
// Following DocumentRepository behavior:
|
signedUrl = signedUrlRes.signedUrl;
|
||||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
|
||||||
|
|
||||||
// 3. Initiate verification
|
// 3. Initiate verification
|
||||||
final List<domain.StaffCertificate> allCerts = await getCertificates();
|
|
||||||
final domain.StaffCertificate currentCert = allCerts.firstWhere(
|
|
||||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
|
||||||
);
|
|
||||||
|
|
||||||
final String staffId = await _service.getStaffId();
|
final String staffId = await _service.getStaffId();
|
||||||
final VerificationResponse verificationRes = await _verificationService
|
final VerificationResponse verificationRes = await _verificationService
|
||||||
.createVerification(
|
.createVerification(
|
||||||
fileUri: uploadRes.fileUri,
|
fileUri: uploadRes.fileUri,
|
||||||
type: certificationType.value,
|
type: 'certification',
|
||||||
category: 'certification',
|
|
||||||
subjectType: 'worker',
|
subjectType: 'worker',
|
||||||
subjectId: staffId,
|
subjectId: staffId,
|
||||||
rules: <String, dynamic>{
|
rules: <String, dynamic>{
|
||||||
'certificateDescription': currentCert.description,
|
'certificateName': name,
|
||||||
|
'certificateIssuer': issuer,
|
||||||
|
'certificateNumber': certificateNumber,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
verificationId = verificationRes.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Update/Create Certificate in Data Connect
|
// 4. Update/Create Certificate in Data Connect
|
||||||
await _service.getStaffRepository().upsertStaffCertificate(
|
await _service.getStaffRepository().upsertStaffCertificate(
|
||||||
certificationType: certificationType,
|
certificationType: certificationType,
|
||||||
name: name,
|
name: name,
|
||||||
status: domain.StaffCertificateStatus.pending,
|
status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
|
||||||
fileUrl: uploadRes.fileUri,
|
fileUrl: signedUrl,
|
||||||
expiry: expiryDate,
|
expiry: expiryDate,
|
||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
certificateNumber: certificateNumber,
|
certificateNumber: certificateNumber,
|
||||||
validationStatus:
|
validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
|
||||||
domain.StaffCertificateValidationStatus.pendingExpertReview,
|
verificationId: verificationId,
|
||||||
verificationId: verificationRes.verificationId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Return updated list or the specific certificate
|
// 5. Return updated list or the specific certificate
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class CertificateUploadCubit extends Cubit<CertificateUploadState>
|
|||||||
emit(state.copyWith(isAttested: value));
|
emit(state.copyWith(isAttested: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSelectedFilePath(String? filePath) {
|
||||||
|
emit(state.copyWith(selectedFilePath: filePath));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteCertificate(ComplianceType type) async {
|
Future<void> deleteCertificate(ComplianceType type) async {
|
||||||
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
||||||
await handleError(
|
await handleError(
|
||||||
|
|||||||
@@ -7,24 +7,28 @@ class CertificateUploadState extends Equatable {
|
|||||||
const CertificateUploadState({
|
const CertificateUploadState({
|
||||||
this.status = CertificateUploadStatus.initial,
|
this.status = CertificateUploadStatus.initial,
|
||||||
this.isAttested = false,
|
this.isAttested = false,
|
||||||
|
this.selectedFilePath,
|
||||||
this.updatedCertificate,
|
this.updatedCertificate,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final CertificateUploadStatus status;
|
final CertificateUploadStatus status;
|
||||||
final bool isAttested;
|
final bool isAttested;
|
||||||
|
final String? selectedFilePath;
|
||||||
final StaffCertificate? updatedCertificate;
|
final StaffCertificate? updatedCertificate;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
CertificateUploadState copyWith({
|
CertificateUploadState copyWith({
|
||||||
CertificateUploadStatus? status,
|
CertificateUploadStatus? status,
|
||||||
bool? isAttested,
|
bool? isAttested,
|
||||||
|
String? selectedFilePath,
|
||||||
StaffCertificate? updatedCertificate,
|
StaffCertificate? updatedCertificate,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return CertificateUploadState(
|
return CertificateUploadState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
isAttested: isAttested ?? this.isAttested,
|
isAttested: isAttested ?? this.isAttested,
|
||||||
|
selectedFilePath: selectedFilePath ?? this.selectedFilePath,
|
||||||
updatedCertificate: updatedCertificate ?? this.updatedCertificate,
|
updatedCertificate: updatedCertificate ?? this.updatedCertificate,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
@@ -34,6 +38,7 @@ class CertificateUploadState extends Equatable {
|
|||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
status,
|
status,
|
||||||
isAttested,
|
isAttested,
|
||||||
|
selectedFilePath,
|
||||||
updatedCertificate,
|
updatedCertificate,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
|
import '../../domain/usecases/upload_certificate_usecase.dart';
|
||||||
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
|
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||||
import '../blocs/certificate_upload/certificate_upload_state.dart';
|
import '../blocs/certificate_upload/certificate_upload_state.dart';
|
||||||
import '../../domain/usecases/upload_certificate_usecase.dart';
|
import '../widgets/certificate_upload_page/index.dart';
|
||||||
|
|
||||||
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
|
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
|
||||||
class CertificateUploadPage extends StatefulWidget {
|
class CertificateUploadPage extends StatefulWidget {
|
||||||
@@ -25,7 +25,6 @@ class CertificateUploadPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||||
String? _selectedFilePath;
|
|
||||||
DateTime? _selectedExpiryDate;
|
DateTime? _selectedExpiryDate;
|
||||||
final TextEditingController _issuerController = TextEditingController();
|
final TextEditingController _issuerController = TextEditingController();
|
||||||
final TextEditingController _numberController = TextEditingController();
|
final TextEditingController _numberController = TextEditingController();
|
||||||
@@ -35,16 +34,21 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
|
|
||||||
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||||
|
|
||||||
|
bool get _isNewCertificate => widget.certificate == null;
|
||||||
|
|
||||||
|
late CertificateUploadCubit _cubit;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_cubit = Modular.get<CertificateUploadCubit>();
|
||||||
|
|
||||||
if (widget.certificate != null) {
|
if (widget.certificate != null) {
|
||||||
_selectedExpiryDate = widget.certificate!.expiryDate;
|
_selectedExpiryDate = widget.certificate!.expiryDate;
|
||||||
_issuerController.text = widget.certificate!.issuer ?? '';
|
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||||
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
||||||
_nameController.text = widget.certificate!.name;
|
_nameController.text = widget.certificate!.name;
|
||||||
_selectedType = widget.certificate!.certificationType;
|
_selectedType = widget.certificate!.certificationType;
|
||||||
_selectedFilePath = widget.certificate?.certificateUrl;
|
|
||||||
} else {
|
} else {
|
||||||
_selectedType = ComplianceType.other;
|
_selectedType = ComplianceType.other;
|
||||||
}
|
}
|
||||||
@@ -80,9 +84,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
_cubit.setSelectedFilePath(path);
|
||||||
_selectedFilePath = path;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +147,10 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<CertificateUploadCubit>(
|
return BlocProvider<CertificateUploadCubit>.value(
|
||||||
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(),
|
value: _cubit..setSelectedFilePath(
|
||||||
|
widget.certificate?.certificateUrl,
|
||||||
|
),
|
||||||
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||||
listener: (BuildContext context, CertificateUploadState state) {
|
listener: (BuildContext context, CertificateUploadState state) {
|
||||||
if (state.status == CertificateUploadStatus.success) {
|
if (state.status == CertificateUploadStatus.success) {
|
||||||
@@ -155,7 +159,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
message: t.staff_certificates.upload_modal.success_snackbar,
|
message: t.staff_certificates.upload_modal.success_snackbar,
|
||||||
type: UiSnackbarType.success,
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
Modular.to.pop(); // Returns to certificates list
|
Modular.to.popSafe(); // Returns to certificates list
|
||||||
} else if (state.status == CertificateUploadStatus.failure) {
|
} else if (state.status == CertificateUploadStatus.failure) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
@@ -170,69 +174,23 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
title:
|
title:
|
||||||
widget.certificate?.name ??
|
widget.certificate?.name ??
|
||||||
t.staff_certificates.upload_modal.title,
|
t.staff_certificates.upload_modal.title,
|
||||||
onLeadingPressed: () => Modular.to.pop(),
|
onLeadingPressed: () => Modular.to.popSafe(),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_PdfFileTypesBanner(
|
PdfFileTypesBanner(
|
||||||
message: t.staff_documents.upload.pdf_banner,
|
message: t.staff_documents.upload.pdf_banner,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
// Name Field
|
CertificateMetadataFields(
|
||||||
Text(
|
nameController: _nameController,
|
||||||
t.staff_certificates.upload_modal.name_label,
|
issuerController: _issuerController,
|
||||||
style: UiTypography.body2m.textPrimary,
|
numberController: _numberController,
|
||||||
),
|
isNewCertificate: _isNewCertificate,
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
TextField(
|
|
||||||
controller: _nameController,
|
|
||||||
enabled: false,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: t.staff_certificates.upload_modal.name_hint,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
|
|
||||||
// Issuer Field
|
|
||||||
Text(
|
|
||||||
t.staff_certificates.upload_modal.issuer_label,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
TextField(
|
|
||||||
controller: _issuerController,
|
|
||||||
enabled: false,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: t.staff_certificates.upload_modal.issuer_hint,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
|
|
||||||
// Certificate Number Field
|
|
||||||
Text(
|
|
||||||
'Certificate Number',
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
TextField(
|
|
||||||
controller: _numberController,
|
|
||||||
enabled: false,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Enter number if applicable',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
@@ -240,44 +198,9 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
// Expiry Date Field
|
ExpiryDateField(
|
||||||
Text(
|
selectedDate: _selectedExpiryDate,
|
||||||
t.staff_certificates.upload_modal.expiry_label,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
InkWell(
|
|
||||||
onTap: _selectDate,
|
onTap: _selectDate,
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space4,
|
|
||||||
vertical: UiConstants.space3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(
|
|
||||||
UiIcons.calendar,
|
|
||||||
size: 20,
|
|
||||||
color: UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Text(
|
|
||||||
_selectedExpiryDate != null
|
|
||||||
? DateFormat(
|
|
||||||
'MMM dd, yyyy',
|
|
||||||
).format(_selectedExpiryDate!)
|
|
||||||
: t.staff_certificates.upload_modal.select_date,
|
|
||||||
style: _selectedExpiryDate != null
|
|
||||||
? UiTypography.body1m.textPrimary
|
|
||||||
: UiTypography.body1m.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
@@ -287,8 +210,8 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body2m.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
_FileSelector(
|
FileSelector(
|
||||||
selectedFilePath: _selectedFilePath,
|
selectedFilePath: state.selectedFilePath,
|
||||||
onTap: _pickFile,
|
onTap: _pickFile,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -297,110 +220,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
bottomNavigationBar: SafeArea(
|
bottomNavigationBar: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: CertificateUploadActions(
|
||||||
mainAxisSize: MainAxisSize.min,
|
isAttested: state.isAttested,
|
||||||
spacing: UiConstants.space4,
|
isFormValid: state.selectedFilePath != null &&
|
||||||
children: <Widget>[
|
|
||||||
// Attestation
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Checkbox(
|
|
||||||
value: state.isAttested,
|
|
||||||
onChanged: (bool? val) =>
|
|
||||||
BlocProvider.of<CertificateUploadCubit>(
|
|
||||||
context,
|
|
||||||
).setAttested(val ?? false),
|
|
||||||
activeColor: UiColors.primary,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
t.staff_documents.upload.attestation,
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed:
|
|
||||||
(_selectedFilePath != null &&
|
|
||||||
state.isAttested &&
|
state.isAttested &&
|
||||||
_nameController.text.isNotEmpty)
|
_nameController.text.isNotEmpty,
|
||||||
? () {
|
isUploading: state.status == CertificateUploadStatus.uploading,
|
||||||
final String? err = _validatePdfFile(
|
hasExistingCertificate: widget.certificate != null,
|
||||||
context,
|
onUploadPressed: () {
|
||||||
_selectedFilePath!,
|
BlocProvider.of<CertificateUploadCubit>(context)
|
||||||
);
|
.uploadCertificate(
|
||||||
if (err != null) {
|
|
||||||
UiSnackbar.show(
|
|
||||||
context,
|
|
||||||
message: err,
|
|
||||||
type: UiSnackbarType.error,
|
|
||||||
margin: const EdgeInsets.all(
|
|
||||||
UiConstants.space4,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BlocProvider.of<CertificateUploadCubit>(
|
|
||||||
context,
|
|
||||||
).uploadCertificate(
|
|
||||||
UploadCertificateParams(
|
UploadCertificateParams(
|
||||||
certificationType: _selectedType!,
|
certificationType: _selectedType!,
|
||||||
name: _nameController.text,
|
name: _nameController.text,
|
||||||
filePath: _selectedFilePath!,
|
filePath: state.selectedFilePath!,
|
||||||
expiryDate: _selectedExpiryDate,
|
expiryDate: _selectedExpiryDate,
|
||||||
issuer: _issuerController.text,
|
issuer: _issuerController.text,
|
||||||
certificateNumber: _numberController.text,
|
certificateNumber: _numberController.text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
: null,
|
onRemovePressed: () => _showRemoveConfirmation(context),
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: UiColors.primary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: UiConstants.space4,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: state.status == CertificateUploadStatus.uploading
|
|
||||||
? const CircularProgressIndicator(
|
|
||||||
color: Colors.white,
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
t.staff_certificates.upload_modal.save,
|
|
||||||
style: UiTypography.body1m.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Remove Button (only if existing)
|
|
||||||
if (widget.certificate != null) ...<Widget>[
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () => _showRemoveConfirmation(context),
|
|
||||||
icon: const Icon(UiIcons.delete, size: 20),
|
|
||||||
label: Text(t.staff_certificates.card.remove),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: UiColors.destructive,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: UiConstants.space4,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
side: const BorderSide(
|
|
||||||
color: UiColors.destructive,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -410,104 +250,3 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Banner displaying accepted file types and size limit for PDF upload.
|
|
||||||
class _PdfFileTypesBanner extends StatelessWidget {
|
|
||||||
const _PdfFileTypesBanner({required this.message});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space4,
|
|
||||||
vertical: UiConstants.space3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primaryForeground,
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Text(message, style: UiTypography.body2r.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FileSelector extends StatelessWidget {
|
|
||||||
const _FileSelector({this.selectedFilePath, required this.onTap});
|
|
||||||
|
|
||||||
final String? selectedFilePath;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (selectedFilePath != null) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: UiColors.primary),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.certificate, color: UiColors.primary),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
selectedFilePath!.split('/').last,
|
|
||||||
style: UiTypography.body1m.primary,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
t.staff_documents.upload.replace,
|
|
||||||
style: UiTypography.body3m.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
height: 120,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
color: UiColors.background,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
Text(
|
|
||||||
t.staff_certificates.upload_modal.drag_drop,
|
|
||||||
style: UiTypography.body2m,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
t.staff_certificates.upload_modal.supported_formats,
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
final List<StaffCertificate> documents = state.certificates;
|
final List<StaffCertificate> documents = state.certificates;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
appBar: UiAppBar(
|
||||||
|
title: t.staff_certificates.title,
|
||||||
|
showBackButton: true,
|
||||||
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
|
|
||||||
class CertificatesHeader extends StatelessWidget {
|
class CertificatesHeader extends StatelessWidget {
|
||||||
|
|
||||||
const CertificatesHeader({
|
const CertificatesHeader({
|
||||||
super.key,
|
super.key,
|
||||||
required this.completedCount,
|
required this.completedCount,
|
||||||
@@ -16,8 +14,12 @@ class CertificatesHeader extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Prevent division by zero
|
// Prevent division by zero
|
||||||
final double progressValue = totalCount == 0 ? 0 : completedCount / totalCount;
|
final double progressValue = totalCount == 0
|
||||||
final int progressPercent = totalCount == 0 ? 0 : (progressValue * 100).round();
|
? 0
|
||||||
|
: completedCount / totalCount;
|
||||||
|
final int progressPercent = totalCount == 0
|
||||||
|
? 0
|
||||||
|
: (progressValue * 100).round();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
@@ -32,39 +34,13 @@ class CertificatesHeader extends StatelessWidget {
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: <Color>[
|
colors: <Color>[
|
||||||
UiColors.primary,
|
|
||||||
UiColors.primary.withValues(alpha: 0.8),
|
UiColors.primary.withValues(alpha: 0.8),
|
||||||
|
UiColors.primary.withValues(alpha: 0.5),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => Modular.to.pop(),
|
|
||||||
child: Container(
|
|
||||||
width: UiConstants.space10,
|
|
||||||
height: UiConstants.space10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
UiIcons.chevronLeft,
|
|
||||||
color: UiColors.white,
|
|
||||||
size: UiConstants.iconMd,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Text(
|
|
||||||
t.staff_certificates.title,
|
|
||||||
style: UiTypography.headline3m.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space8),
|
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -101,7 +77,9 @@ class CertificatesHeader extends StatelessWidget {
|
|||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
t.staff_certificates.progress.verified_count(
|
t.staff_certificates.progress.verified_count(
|
||||||
completed: completedCount, total: totalCount),
|
completed: completedCount,
|
||||||
|
total: totalCount,
|
||||||
|
),
|
||||||
style: UiTypography.body3r.copyWith(
|
style: UiTypography.body3r.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.7),
|
color: UiColors.white.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -50,21 +50,11 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
|
|||||||
);
|
);
|
||||||
final String description = (currentDoc.description ?? '').toLowerCase();
|
final String description = (currentDoc.description ?? '').toLowerCase();
|
||||||
|
|
||||||
String verificationType = 'government_id';
|
|
||||||
if (description.contains('permit')) {
|
|
||||||
verificationType = 'work_permit';
|
|
||||||
} else if (description.contains('passport')) {
|
|
||||||
verificationType = 'passport';
|
|
||||||
} else if (description.contains('ssn') ||
|
|
||||||
description.contains('social security')) {
|
|
||||||
verificationType = 'ssn';
|
|
||||||
}
|
|
||||||
|
|
||||||
final String staffId = await _service.getStaffId();
|
final String staffId = await _service.getStaffId();
|
||||||
final VerificationResponse verificationRes = await _verificationService
|
final VerificationResponse verificationRes = await _verificationService
|
||||||
.createVerification(
|
.createVerification(
|
||||||
fileUri: uploadRes.fileUri,
|
fileUri: uploadRes.fileUri,
|
||||||
type: verificationType,
|
type: 'government_id',
|
||||||
subjectType: 'worker',
|
subjectType: 'worker',
|
||||||
subjectId: staffId,
|
subjectId: staffId,
|
||||||
rules: <String, dynamic>{
|
rules: <String, dynamic>{
|
||||||
@@ -75,7 +65,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
|
|||||||
// 4. Update/Create StaffDocument in Data Connect
|
// 4. Update/Create StaffDocument in Data Connect
|
||||||
await _service.getStaffRepository().upsertStaffDocument(
|
await _service.getStaffRepository().upsertStaffDocument(
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
documentUrl: uploadRes.fileUri,
|
documentUrl: signedUrlRes.signedUrl,
|
||||||
status: domain.DocumentStatus.pending,
|
status: domain.DocumentStatus.pending,
|
||||||
verificationId: verificationRes.verificationId,
|
verificationId: verificationRes.verificationId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
|||||||
emit(state.copyWith(isAttested: value));
|
emit(state.copyWith(isAttested: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the selected file path for the document.
|
||||||
|
void setSelectedFilePath(String filePath) {
|
||||||
|
emit(state.copyWith(selectedFilePath: filePath));
|
||||||
|
}
|
||||||
|
|
||||||
/// Uploads the selected document if the user has attested.
|
/// Uploads the selected document if the user has attested.
|
||||||
///
|
///
|
||||||
/// Requires [state.isAttested] to be true before proceeding.
|
/// Requires [state.isAttested] to be true before proceeding.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class DocumentUploadState extends Equatable {
|
|||||||
const DocumentUploadState({
|
const DocumentUploadState({
|
||||||
this.status = DocumentUploadStatus.initial,
|
this.status = DocumentUploadStatus.initial,
|
||||||
this.isAttested = false,
|
this.isAttested = false,
|
||||||
|
this.selectedFilePath,
|
||||||
this.documentUrl,
|
this.documentUrl,
|
||||||
this.updatedDocument,
|
this.updatedDocument,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
@@ -14,6 +15,7 @@ class DocumentUploadState extends Equatable {
|
|||||||
|
|
||||||
final DocumentUploadStatus status;
|
final DocumentUploadStatus status;
|
||||||
final bool isAttested;
|
final bool isAttested;
|
||||||
|
final String? selectedFilePath;
|
||||||
final String? documentUrl;
|
final String? documentUrl;
|
||||||
final StaffDocument? updatedDocument;
|
final StaffDocument? updatedDocument;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
@@ -21,6 +23,7 @@ class DocumentUploadState extends Equatable {
|
|||||||
DocumentUploadState copyWith({
|
DocumentUploadState copyWith({
|
||||||
DocumentUploadStatus? status,
|
DocumentUploadStatus? status,
|
||||||
bool? isAttested,
|
bool? isAttested,
|
||||||
|
String? selectedFilePath,
|
||||||
String? documentUrl,
|
String? documentUrl,
|
||||||
StaffDocument? updatedDocument,
|
StaffDocument? updatedDocument,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
@@ -28,6 +31,7 @@ class DocumentUploadState extends Equatable {
|
|||||||
return DocumentUploadState(
|
return DocumentUploadState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
isAttested: isAttested ?? this.isAttested,
|
isAttested: isAttested ?? this.isAttested,
|
||||||
|
selectedFilePath: selectedFilePath ?? this.selectedFilePath,
|
||||||
documentUrl: documentUrl ?? this.documentUrl,
|
documentUrl: documentUrl ?? this.documentUrl,
|
||||||
updatedDocument: updatedDocument ?? this.updatedDocument,
|
updatedDocument: updatedDocument ?? this.updatedDocument,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
@@ -38,6 +42,7 @@ class DocumentUploadState extends Equatable {
|
|||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
status,
|
status,
|
||||||
isAttested,
|
isAttested,
|
||||||
|
selectedFilePath,
|
||||||
documentUrl,
|
documentUrl,
|
||||||
updatedDocument,
|
updatedDocument,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -13,12 +11,13 @@ import '../blocs/document_upload/document_upload_state.dart';
|
|||||||
import '../widgets/document_upload/document_attestation_checkbox.dart';
|
import '../widgets/document_upload/document_attestation_checkbox.dart';
|
||||||
import '../widgets/document_upload/document_file_selector.dart';
|
import '../widgets/document_upload/document_file_selector.dart';
|
||||||
import '../widgets/document_upload/document_upload_footer.dart';
|
import '../widgets/document_upload/document_upload_footer.dart';
|
||||||
|
import '../widgets/document_upload/pdf_file_types_banner.dart';
|
||||||
|
|
||||||
/// Allows staff to select and submit a single PDF document for verification.
|
/// Allows staff to select and submit a single PDF document for verification.
|
||||||
///
|
///
|
||||||
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
|
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
|
||||||
/// file selection → attestation → submit → poll for result.
|
/// file selection → attestation → submit → poll for result.
|
||||||
class DocumentUploadPage extends StatefulWidget {
|
class DocumentUploadPage extends StatelessWidget {
|
||||||
const DocumentUploadPage({
|
const DocumentUploadPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.document,
|
required this.document,
|
||||||
@@ -31,64 +30,17 @@ class DocumentUploadPage extends StatefulWidget {
|
|||||||
/// Optional URL of an already-uploaded document.
|
/// Optional URL of an already-uploaded document.
|
||||||
final String? initialUrl;
|
final String? initialUrl;
|
||||||
|
|
||||||
@override
|
|
||||||
State<DocumentUploadPage> createState() => _DocumentUploadPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
|
||||||
String? _selectedFilePath;
|
|
||||||
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
|
||||||
|
|
||||||
static const int _kMaxFileSizeBytes = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
Future<void> _pickFile() async {
|
|
||||||
final String? path = await _filePicker.pickFile(
|
|
||||||
allowedExtensions: <String>['pdf'],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path != null) {
|
|
||||||
final String? error = _validatePdfFile(context, path);
|
|
||||||
if (error != null) {
|
|
||||||
UiSnackbar.show(
|
|
||||||
context,
|
|
||||||
message: error,
|
|
||||||
type: UiSnackbarType.error,
|
|
||||||
margin: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_selectedFilePath = path;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validatePdfFile(BuildContext context, String path) {
|
|
||||||
final File file = File(path);
|
|
||||||
if (!file.existsSync()) return context.t.common.file_not_found;
|
|
||||||
final String ext = path.split('.').last.toLowerCase();
|
|
||||||
if (ext != 'pdf') {
|
|
||||||
return context.t.staff_documents.upload.pdf_banner;
|
|
||||||
}
|
|
||||||
final int size = file.lengthSync();
|
|
||||||
if (size > _kMaxFileSizeBytes) {
|
|
||||||
return context.t.staff_documents.upload.pdf_banner;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (widget.initialUrl != null) {
|
|
||||||
_selectedFilePath = widget.initialUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BlocProvider<DocumentUploadCubit>(
|
return BlocProvider<DocumentUploadCubit>(
|
||||||
create: (BuildContext _) => Modular.get<DocumentUploadCubit>(),
|
create: (BuildContext _) {
|
||||||
|
final DocumentUploadCubit cubit =
|
||||||
|
Modular.get<DocumentUploadCubit>();
|
||||||
|
if (initialUrl != null) {
|
||||||
|
cubit.setSelectedFilePath(initialUrl!);
|
||||||
|
}
|
||||||
|
return cubit;
|
||||||
|
},
|
||||||
child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>(
|
child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>(
|
||||||
listener: (BuildContext context, DocumentUploadState state) {
|
listener: (BuildContext context, DocumentUploadState state) {
|
||||||
if (state.status == DocumentUploadStatus.success) {
|
if (state.status == DocumentUploadStatus.success) {
|
||||||
@@ -109,8 +61,8 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
|||||||
builder: (BuildContext context, DocumentUploadState state) {
|
builder: (BuildContext context, DocumentUploadState state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: UiAppBar(
|
appBar: UiAppBar(
|
||||||
title: widget.document.name,
|
title: document.name,
|
||||||
subtitle: widget.document.description,
|
subtitle: document.description,
|
||||||
onLeadingPressed: () => Modular.to.toDocuments(),
|
onLeadingPressed: () => Modular.to.toDocuments(),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@@ -118,13 +70,16 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_PdfFileTypesBanner(
|
PdfFileTypesBanner(
|
||||||
message: t.staff_documents.upload.pdf_banner,
|
message: t.staff_documents.upload.pdf_banner,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
DocumentFileSelector(
|
DocumentFileSelector(
|
||||||
selectedFilePath: _selectedFilePath,
|
selectedFilePath: state.selectedFilePath,
|
||||||
onTap: _pickFile,
|
onFileSelected: (String path) {
|
||||||
|
BlocProvider.of<DocumentUploadCubit>(context)
|
||||||
|
.setSelectedFilePath(path);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -150,26 +105,12 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
|||||||
DocumentUploadFooter(
|
DocumentUploadFooter(
|
||||||
isUploading:
|
isUploading:
|
||||||
state.status == DocumentUploadStatus.uploading,
|
state.status == DocumentUploadStatus.uploading,
|
||||||
canSubmit: _selectedFilePath != null && state.isAttested,
|
canSubmit: state.selectedFilePath != null && state.isAttested,
|
||||||
onSubmit: () {
|
onSubmit: () {
|
||||||
final String? err = _validatePdfFile(
|
BlocProvider.of<DocumentUploadCubit>(context)
|
||||||
context,
|
.uploadDocument(
|
||||||
_selectedFilePath!,
|
document.documentId,
|
||||||
);
|
state.selectedFilePath!,
|
||||||
if (err != null) {
|
|
||||||
UiSnackbar.show(
|
|
||||||
context,
|
|
||||||
message: err,
|
|
||||||
type: UiSnackbarType.error,
|
|
||||||
margin: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BlocProvider.of<DocumentUploadCubit>(
|
|
||||||
context,
|
|
||||||
).uploadDocument(
|
|
||||||
widget.document.documentId,
|
|
||||||
_selectedFilePath!,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -183,36 +124,3 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Banner displaying accepted file types and size limit for PDF upload.
|
|
||||||
class _PdfFileTypesBanner extends StatelessWidget {
|
|
||||||
const _PdfFileTypesBanner({required this.message});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space4,
|
|
||||||
vertical: UiConstants.space3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primaryForeground,
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Text(message, style: UiTypography.body2r.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// ignore: depend_on_referenced_packages
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import 'document_selected_card.dart';
|
import 'document_selected_card.dart';
|
||||||
|
|
||||||
@@ -9,33 +13,89 @@ import 'document_selected_card.dart';
|
|||||||
///
|
///
|
||||||
/// Shows the selected file name when a file has been chosen, or an
|
/// Shows the selected file name when a file has been chosen, or an
|
||||||
/// upload icon with a prompt when no file is selected yet.
|
/// upload icon with a prompt when no file is selected yet.
|
||||||
class DocumentFileSelector extends StatelessWidget {
|
class DocumentFileSelector extends StatefulWidget {
|
||||||
const DocumentFileSelector({
|
const DocumentFileSelector({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onTap,
|
this.onFileSelected,
|
||||||
this.selectedFilePath,
|
this.selectedFilePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Called when the user taps the selector to pick a file.
|
/// Called when a file is successfully selected and validated.
|
||||||
final VoidCallback onTap;
|
final Function(String)? onFileSelected;
|
||||||
|
|
||||||
/// The local path of the currently selected file, or null if none chosen.
|
/// The local path of the currently selected file, or null if none chosen.
|
||||||
final String? selectedFilePath;
|
final String? selectedFilePath;
|
||||||
|
|
||||||
bool get _hasFile => selectedFilePath != null;
|
@override
|
||||||
|
State<DocumentFileSelector> createState() => _DocumentFileSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentFileSelectorState extends State<DocumentFileSelector> {
|
||||||
|
late String? _selectedFilePath;
|
||||||
|
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||||
|
static const int _kMaxFileSizeBytes = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedFilePath = widget.selectedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasFile => _selectedFilePath != null;
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final String? path = await _filePicker.pickFile(
|
||||||
|
allowedExtensions: <String>['pdf'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
final String? error = _validatePdfFile(context, path);
|
||||||
|
if (error != null) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: error,
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
margin: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selectedFilePath = path;
|
||||||
|
});
|
||||||
|
widget.onFileSelected?.call(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePdfFile(BuildContext context, String path) {
|
||||||
|
final File file = File(path);
|
||||||
|
if (!file.existsSync()) return context.t.common.file_not_found;
|
||||||
|
final String ext = path.split('.').last.toLowerCase();
|
||||||
|
if (ext != 'pdf') {
|
||||||
|
return context.t.staff_documents.upload.pdf_banner;
|
||||||
|
}
|
||||||
|
final int size = file.lengthSync();
|
||||||
|
if (size > _kMaxFileSizeBytes) {
|
||||||
|
return context.t.staff_documents.upload.pdf_banner;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_hasFile) {
|
if (_hasFile) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: _pickFile,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
child: DocumentSelectedCard(selectedFilePath: selectedFilePath!),
|
child: DocumentSelectedCard(selectedFilePath: _selectedFilePath!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: _pickFile,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 180,
|
height: 180,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
|
import 'package:flutter_modular/flutter_modular.dart'
|
||||||
|
hide ModularWatchExtension;
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../blocs/i9/form_i9_cubit.dart';
|
import '../blocs/i9/form_i9_cubit.dart';
|
||||||
@@ -18,11 +20,56 @@ class FormI9Page extends StatefulWidget {
|
|||||||
|
|
||||||
class _FormI9PageState extends State<FormI9Page> {
|
class _FormI9PageState extends State<FormI9Page> {
|
||||||
final List<String> _usStates = <String>[
|
final List<String> _usStates = <String>[
|
||||||
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
'AL',
|
||||||
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
'AK',
|
||||||
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
'AZ',
|
||||||
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
'AR',
|
||||||
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
|
'CA',
|
||||||
|
'CO',
|
||||||
|
'CT',
|
||||||
|
'DE',
|
||||||
|
'FL',
|
||||||
|
'GA',
|
||||||
|
'HI',
|
||||||
|
'ID',
|
||||||
|
'IL',
|
||||||
|
'IN',
|
||||||
|
'IA',
|
||||||
|
'KS',
|
||||||
|
'KY',
|
||||||
|
'LA',
|
||||||
|
'ME',
|
||||||
|
'MD',
|
||||||
|
'MA',
|
||||||
|
'MI',
|
||||||
|
'MN',
|
||||||
|
'MS',
|
||||||
|
'MO',
|
||||||
|
'MT',
|
||||||
|
'NE',
|
||||||
|
'NV',
|
||||||
|
'NH',
|
||||||
|
'NJ',
|
||||||
|
'NM',
|
||||||
|
'NY',
|
||||||
|
'NC',
|
||||||
|
'ND',
|
||||||
|
'OH',
|
||||||
|
'OK',
|
||||||
|
'OR',
|
||||||
|
'PA',
|
||||||
|
'RI',
|
||||||
|
'SC',
|
||||||
|
'SD',
|
||||||
|
'TN',
|
||||||
|
'TX',
|
||||||
|
'UT',
|
||||||
|
'VT',
|
||||||
|
'VA',
|
||||||
|
'WA',
|
||||||
|
'WV',
|
||||||
|
'WI',
|
||||||
|
'WY',
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,10 +83,19 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<Map<String, String>> _steps = <Map<String, String>>[
|
final List<Map<String, String>> _steps = <Map<String, String>>[
|
||||||
<String, String>{'title': 'Personal Information', 'subtitle': 'Name and contact details'},
|
<String, String>{
|
||||||
|
'title': 'Personal Information',
|
||||||
|
'subtitle': 'Name and contact details',
|
||||||
|
},
|
||||||
<String, String>{'title': 'Address', 'subtitle': 'Your current address'},
|
<String, String>{'title': 'Address', 'subtitle': 'Your current address'},
|
||||||
<String, String>{'title': 'Citizenship Status', 'subtitle': 'Work authorization verification'},
|
<String, String>{
|
||||||
<String, String>{'title': 'Review & Sign', 'subtitle': 'Confirm your information'},
|
'title': 'Citizenship Status',
|
||||||
|
'subtitle': 'Work authorization verification',
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': 'Review & Sign',
|
||||||
|
'subtitle': 'Confirm your information',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
bool _canProceed(FormI9State state) {
|
bool _canProceed(FormI9State state) {
|
||||||
@@ -77,13 +133,27 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9;
|
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.i9;
|
||||||
|
|
||||||
final List<Map<String, String>> steps = <Map<String, String>>[
|
final List<Map<String, String>> steps = <Map<String, String>>[
|
||||||
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_sub},
|
<String, String>{
|
||||||
<String, String>{'title': i18n.steps.address, 'subtitle': i18n.steps.address_sub},
|
'title': i18n.steps.personal,
|
||||||
<String, String>{'title': i18n.steps.citizenship, 'subtitle': i18n.steps.citizenship_sub},
|
'subtitle': i18n.steps.personal_sub,
|
||||||
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.steps.review_sub},
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.address,
|
||||||
|
'subtitle': i18n.steps.address_sub,
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.citizenship,
|
||||||
|
'subtitle': i18n.steps.citizenship_sub,
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.review,
|
||||||
|
'subtitle': i18n.steps.review_sub,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return BlocProvider<FormI9Cubit>.value(
|
return BlocProvider<FormI9Cubit>.value(
|
||||||
@@ -95,7 +165,9 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
} else if (state.status == FormI9Status.failure) {
|
} else if (state.status == FormI9Status.failure) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
|
message: translateErrorKey(
|
||||||
|
state.errorMessage ?? 'An error occurred',
|
||||||
|
),
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
margin: const EdgeInsets.only(
|
margin: const EdgeInsets.only(
|
||||||
left: UiConstants.space4,
|
left: UiConstants.space4,
|
||||||
@@ -106,7 +178,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (BuildContext context, FormI9State state) {
|
builder: (BuildContext context, FormI9State state) {
|
||||||
if (state.status == FormI9Status.success) return _buildSuccessView(i18n);
|
if (state.status == FormI9Status.success)
|
||||||
|
return _buildSuccessView(i18n);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background,
|
backgroundColor: UiColors.background,
|
||||||
@@ -175,7 +248,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => Modular.to.pop(true),
|
onPressed: () => Modular.to.popSafe(true),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: UiColors.primary,
|
backgroundColor: UiColors.primary,
|
||||||
foregroundColor: UiColors.white,
|
foregroundColor: UiColors.white,
|
||||||
@@ -187,7 +260,11 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: Text(Translations.of(context).staff_compliance.tax_forms.w4.back_to_docs),
|
child: Text(
|
||||||
|
Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.w4.back_to_docs,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -218,7 +295,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Modular.to.pop(),
|
onTap: () => Modular.to.popSafe(),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
UiIcons.arrowLeft,
|
UiIcons.arrowLeft,
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
@@ -229,10 +306,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(i18n.title, style: UiTypography.headline4m.white),
|
||||||
i18n.title,
|
|
||||||
style: UiTypography.headline4m.white,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
i18n.subtitle,
|
i18n.subtitle,
|
||||||
style: UiTypography.body3r.copyWith(
|
style: UiTypography.body3r.copyWith(
|
||||||
@@ -245,10 +319,9 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
Row(
|
Row(
|
||||||
children: steps
|
children: steps.asMap().entries.map((
|
||||||
.asMap()
|
MapEntry<int, Map<String, String>> entry,
|
||||||
.entries
|
) {
|
||||||
.map((MapEntry<int, Map<String, String>> entry) {
|
|
||||||
final int idx = entry.key;
|
final int idx = entry.key;
|
||||||
final bool isLast = idx == steps.length - 1;
|
final bool isLast = idx == steps.length - 1;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -384,7 +457,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.first_name,
|
i18n.fields.first_name,
|
||||||
value: state.firstName,
|
value: state.firstName,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().firstNameChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().firstNameChanged(val),
|
||||||
placeholder: i18n.fields.hints.first_name,
|
placeholder: i18n.fields.hints.first_name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -393,7 +467,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.last_name,
|
i18n.fields.last_name,
|
||||||
value: state.lastName,
|
value: state.lastName,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().lastNameChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().lastNameChanged(val),
|
||||||
placeholder: i18n.fields.hints.last_name,
|
placeholder: i18n.fields.hints.last_name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -406,7 +481,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.middle_initial,
|
i18n.fields.middle_initial,
|
||||||
value: state.middleInitial,
|
value: state.middleInitial,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().middleInitialChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().middleInitialChanged(val),
|
||||||
placeholder: i18n.fields.hints.middle_initial,
|
placeholder: i18n.fields.hints.middle_initial,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -416,7 +492,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.other_last_names,
|
i18n.fields.other_last_names,
|
||||||
value: state.otherLastNames,
|
value: state.otherLastNames,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().otherLastNamesChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().otherLastNamesChanged(val),
|
||||||
placeholder: i18n.fields.maiden_name,
|
placeholder: i18n.fields.maiden_name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -426,7 +503,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.dob,
|
i18n.fields.dob,
|
||||||
value: state.dob,
|
value: state.dob,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().dobChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().dobChanged(val),
|
||||||
placeholder: i18n.fields.hints.dob,
|
placeholder: i18n.fields.hints.dob,
|
||||||
keyboardType: TextInputType.datetime,
|
keyboardType: TextInputType.datetime,
|
||||||
),
|
),
|
||||||
@@ -446,7 +524,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.email,
|
i18n.fields.email,
|
||||||
value: state.email,
|
value: state.email,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().emailChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().emailChanged(val),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
placeholder: i18n.fields.hints.email,
|
placeholder: i18n.fields.hints.email,
|
||||||
),
|
),
|
||||||
@@ -454,7 +533,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.phone,
|
i18n.fields.phone,
|
||||||
value: state.phone,
|
value: state.phone,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().phoneChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().phoneChanged(val),
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
placeholder: i18n.fields.hints.phone,
|
placeholder: i18n.fields.hints.phone,
|
||||||
),
|
),
|
||||||
@@ -472,14 +552,16 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.address_long,
|
i18n.fields.address_long,
|
||||||
value: state.address,
|
value: state.address,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().addressChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().addressChanged(val),
|
||||||
placeholder: i18n.fields.hints.address,
|
placeholder: i18n.fields.hints.address,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.apt,
|
i18n.fields.apt,
|
||||||
value: state.aptNumber,
|
value: state.aptNumber,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().aptNumberChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().aptNumberChanged(val),
|
||||||
placeholder: i18n.fields.hints.apt,
|
placeholder: i18n.fields.hints.apt,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
@@ -490,7 +572,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.city,
|
i18n.fields.city,
|
||||||
value: state.city,
|
value: state.city,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().cityChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().cityChanged(val),
|
||||||
placeholder: i18n.fields.hints.city,
|
placeholder: i18n.fields.hints.city,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -541,7 +624,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.zip,
|
i18n.fields.zip,
|
||||||
value: state.zipCode,
|
value: state.zipCode,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().zipCodeChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().zipCodeChanged(val),
|
||||||
placeholder: i18n.fields.hints.zip,
|
placeholder: i18n.fields.hints.zip,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
@@ -557,24 +641,11 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(i18n.fields.attestation, style: UiTypography.body2m.textPrimary),
|
||||||
i18n.fields.attestation,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
_buildRadioOption(
|
_buildRadioOption(context, state, 'CITIZEN', i18n.fields.citizen),
|
||||||
context,
|
|
||||||
state,
|
|
||||||
'CITIZEN',
|
|
||||||
i18n.fields.citizen,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildRadioOption(
|
_buildRadioOption(context, state, 'NONCITIZEN', i18n.fields.noncitizen),
|
||||||
context,
|
|
||||||
state,
|
|
||||||
'NONCITIZEN',
|
|
||||||
i18n.fields.noncitizen,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildRadioOption(
|
_buildRadioOption(
|
||||||
context,
|
context,
|
||||||
@@ -587,7 +658,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.uscis_number_label,
|
i18n.fields.uscis_number_label,
|
||||||
value: state.uscisNumber,
|
value: state.uscisNumber,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().uscisNumberChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormI9Cubit>().uscisNumberChanged(val),
|
||||||
placeholder: i18n.fields.hints.uscis,
|
placeholder: i18n.fields.hints.uscis,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -607,19 +679,25 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.admission_number,
|
i18n.fields.admission_number,
|
||||||
value: state.admissionNumber,
|
value: state.admissionNumber,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().admissionNumberChanged(val),
|
onChanged: (String val) => context
|
||||||
|
.read<FormI9Cubit>()
|
||||||
|
.admissionNumberChanged(val),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.passport,
|
i18n.fields.passport,
|
||||||
value: state.passportNumber,
|
value: state.passportNumber,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().passportNumberChanged(val),
|
onChanged: (String val) => context
|
||||||
|
.read<FormI9Cubit>()
|
||||||
|
.passportNumberChanged(val),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.country,
|
i18n.fields.country,
|
||||||
value: state.countryIssuance,
|
value: state.countryIssuance,
|
||||||
onChanged: (String val) => context.read<FormI9Cubit>().countryIssuanceChanged(val),
|
onChanged: (String val) => context
|
||||||
|
.read<FormI9Cubit>()
|
||||||
|
.countryIssuanceChanged(val),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -667,10 +745,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(label, style: UiTypography.body2m.textPrimary),
|
||||||
label,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -704,8 +779,14 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
style: UiTypography.headline4m.copyWith(fontSize: 14),
|
style: UiTypography.headline4m.copyWith(fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'),
|
_buildSummaryRow(
|
||||||
_buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'),
|
i18n.fields.summary_name,
|
||||||
|
'${state.firstName} ${state.lastName}',
|
||||||
|
),
|
||||||
|
_buildSummaryRow(
|
||||||
|
i18n.fields.summary_address,
|
||||||
|
'${state.address}, ${state.city}',
|
||||||
|
),
|
||||||
_buildSummaryRow(
|
_buildSummaryRow(
|
||||||
i18n.fields.summary_ssn,
|
i18n.fields.summary_ssn,
|
||||||
'***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}',
|
'***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}',
|
||||||
@@ -780,10 +861,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
|
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Text(
|
Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
|
||||||
i18n.fields.date_label,
|
|
||||||
style: UiTypography.body3m.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space1 + 2),
|
const SizedBox(height: UiConstants.space1 + 2),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -811,10 +889,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(label, style: UiTypography.body2r.textSecondary),
|
||||||
label,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
@@ -828,7 +903,9 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getReadableCitizenship(String status) {
|
String _getReadableCitizenship(String status) {
|
||||||
final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields;
|
final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.i9.fields;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'CITIZEN':
|
case 'CITIZEN':
|
||||||
return i18n.status_us_citizen;
|
return i18n.status_us_citizen;
|
||||||
@@ -848,7 +925,9 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
FormI9State state,
|
FormI9State state,
|
||||||
List<Map<String, String>> steps,
|
List<Map<String, String>> steps,
|
||||||
) {
|
) {
|
||||||
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9;
|
final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.i9;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
@@ -883,10 +962,7 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(i18n.back, style: UiTypography.body2r.textPrimary),
|
||||||
i18n.back,
|
|
||||||
style: UiTypography.body2r.textPrimary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -895,8 +971,8 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (
|
onPressed:
|
||||||
_canProceed(state) &&
|
(_canProceed(state) &&
|
||||||
state.status != FormI9Status.submitting)
|
state.status != FormI9Status.submitting)
|
||||||
? () => _handleNext(context, state.currentStep)
|
? () => _handleNext(context, state.currentStep)
|
||||||
: null,
|
: null,
|
||||||
@@ -931,7 +1007,11 @@ class _FormI9PageState extends State<FormI9Page> {
|
|||||||
),
|
),
|
||||||
if (state.currentStep < steps.length - 1) ...<Widget>[
|
if (state.currentStep < steps.length - 1) ...<Widget>[
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white),
|
const Icon(
|
||||||
|
UiIcons.arrowRight,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
|
import 'package:flutter_modular/flutter_modular.dart'
|
||||||
|
hide ModularWatchExtension;
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../blocs/w4/form_w4_cubit.dart';
|
import '../blocs/w4/form_w4_cubit.dart';
|
||||||
@@ -84,7 +86,10 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
<String, String>{'title': 'Filing Status', 'subtitle': 'Step 1c'},
|
<String, String>{'title': 'Filing Status', 'subtitle': 'Step 1c'},
|
||||||
<String, String>{'title': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'},
|
<String, String>{'title': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'},
|
||||||
<String, String>{'title': 'Dependents', 'subtitle': 'Step 3'},
|
<String, String>{'title': 'Dependents', 'subtitle': 'Step 3'},
|
||||||
<String, String>{'title': 'Other Adjustments', 'subtitle': 'Step 4 (optional)'},
|
<String, String>{
|
||||||
|
'title': 'Other Adjustments',
|
||||||
|
'subtitle': 'Step 4 (optional)',
|
||||||
|
},
|
||||||
<String, String>{'title': 'Review & Sign', 'subtitle': 'Step 5'},
|
<String, String>{'title': 'Review & Sign', 'subtitle': 'Step 5'},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,23 +121,41 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
context.read<FormW4Cubit>().previousStep();
|
context.read<FormW4Cubit>().previousStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
int _totalCredits(FormW4State state) {
|
int _totalCredits(FormW4State state) {
|
||||||
return (state.qualifyingChildren * 2000) +
|
return (state.qualifyingChildren * 2000) + (state.otherDependents * 500);
|
||||||
(state.otherDependents * 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4;
|
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.w4;
|
||||||
|
|
||||||
final List<Map<String, String>> steps = <Map<String, String>>[
|
final List<Map<String, String>> steps = <Map<String, String>>[
|
||||||
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')},
|
<String, String>{
|
||||||
<String, String>{'title': i18n.steps.filing, 'subtitle': i18n.step_label(current: '1c', total: '5')},
|
'title': i18n.steps.personal,
|
||||||
<String, String>{'title': i18n.steps.multiple_jobs, 'subtitle': i18n.step_label(current: '2', total: '5')},
|
'subtitle': i18n.step_label(current: '1', total: '5'),
|
||||||
<String, String>{'title': i18n.steps.dependents, 'subtitle': i18n.step_label(current: '3', total: '5')},
|
},
|
||||||
<String, String>{'title': i18n.steps.adjustments, 'subtitle': i18n.step_label(current: '4', total: '5')},
|
<String, String>{
|
||||||
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.step_label(current: '5', total: '5')},
|
'title': i18n.steps.filing,
|
||||||
|
'subtitle': i18n.step_label(current: '1c', total: '5'),
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.multiple_jobs,
|
||||||
|
'subtitle': i18n.step_label(current: '2', total: '5'),
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.dependents,
|
||||||
|
'subtitle': i18n.step_label(current: '3', total: '5'),
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.adjustments,
|
||||||
|
'subtitle': i18n.step_label(current: '4', total: '5'),
|
||||||
|
},
|
||||||
|
<String, String>{
|
||||||
|
'title': i18n.steps.review,
|
||||||
|
'subtitle': i18n.step_label(current: '5', total: '5'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return BlocProvider<FormW4Cubit>.value(
|
return BlocProvider<FormW4Cubit>.value(
|
||||||
@@ -144,7 +167,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
} else if (state.status == FormW4Status.failure) {
|
} else if (state.status == FormW4Status.failure) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
|
message: translateErrorKey(
|
||||||
|
state.errorMessage ?? 'An error occurred',
|
||||||
|
),
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
margin: const EdgeInsets.only(
|
margin: const EdgeInsets.only(
|
||||||
left: UiConstants.space4,
|
left: UiConstants.space4,
|
||||||
@@ -155,7 +180,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (BuildContext context, FormW4State state) {
|
builder: (BuildContext context, FormW4State state) {
|
||||||
if (state.status == FormW4Status.success) return _buildSuccessView(i18n);
|
if (state.status == FormW4Status.success)
|
||||||
|
return _buildSuccessView(i18n);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background,
|
backgroundColor: UiColors.background,
|
||||||
@@ -224,7 +250,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => Modular.to.pop(true),
|
onPressed: () => Modular.to.popSafe(true),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: UiColors.primary,
|
backgroundColor: UiColors.primary,
|
||||||
foregroundColor: UiColors.white,
|
foregroundColor: UiColors.white,
|
||||||
@@ -267,7 +293,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Modular.to.pop(),
|
onTap: () => Modular.to.popSafe(),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
UiIcons.arrowLeft,
|
UiIcons.arrowLeft,
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
@@ -278,10 +304,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(i18n.title, style: UiTypography.headline4m.white),
|
||||||
i18n.title,
|
|
||||||
style: UiTypography.headline4m.white,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
i18n.subtitle,
|
i18n.subtitle,
|
||||||
style: UiTypography.body3r.copyWith(
|
style: UiTypography.body3r.copyWith(
|
||||||
@@ -294,10 +317,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
Row(
|
Row(
|
||||||
children: steps
|
children: steps.asMap().entries.map((
|
||||||
.asMap()
|
MapEntry<int, Map<String, String>> entry,
|
||||||
.entries
|
) {
|
||||||
.map((MapEntry<int, Map<String, String>> entry) {
|
|
||||||
final int idx = entry.key;
|
final int idx = entry.key;
|
||||||
final bool isLast = idx == steps.length - 1;
|
final bool isLast = idx == steps.length - 1;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -434,7 +456,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.first_name,
|
i18n.fields.first_name,
|
||||||
value: state.firstName,
|
value: state.firstName,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().firstNameChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().firstNameChanged(val),
|
||||||
placeholder: i18n.fields.placeholder_john,
|
placeholder: i18n.fields.placeholder_john,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -443,7 +466,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
child: _buildTextField(
|
child: _buildTextField(
|
||||||
i18n.fields.last_name,
|
i18n.fields.last_name,
|
||||||
value: state.lastName,
|
value: state.lastName,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().lastNameChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().lastNameChanged(val),
|
||||||
placeholder: i18n.fields.placeholder_smith,
|
placeholder: i18n.fields.placeholder_smith,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -465,14 +489,16 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.address,
|
i18n.fields.address,
|
||||||
value: state.address,
|
value: state.address,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().addressChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().addressChanged(val),
|
||||||
placeholder: i18n.fields.placeholder_address,
|
placeholder: i18n.fields.placeholder_address,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.city_state_zip,
|
i18n.fields.city_state_zip,
|
||||||
value: state.cityStateZip,
|
value: state.cityStateZip,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().cityStateZipChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().cityStateZipChanged(val),
|
||||||
placeholder: i18n.fields.placeholder_csz,
|
placeholder: i18n.fields.placeholder_csz,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -506,21 +532,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
_buildRadioOption(
|
_buildRadioOption(context, state, 'SINGLE', i18n.fields.single, null),
|
||||||
context,
|
|
||||||
state,
|
|
||||||
'SINGLE',
|
|
||||||
i18n.fields.single,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildRadioOption(
|
_buildRadioOption(context, state, 'MARRIED', i18n.fields.married, null),
|
||||||
context,
|
|
||||||
state,
|
|
||||||
'MARRIED',
|
|
||||||
i18n.fields.married,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
_buildRadioOption(
|
_buildRadioOption(
|
||||||
context,
|
context,
|
||||||
@@ -573,16 +587,10 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(label, style: UiTypography.body2m.textPrimary),
|
||||||
label,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
if (subLabel != null) ...<Widget>[
|
if (subLabel != null) ...<Widget>[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(subLabel, style: UiTypography.body3r.textSecondary),
|
||||||
subLabel,
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -609,11 +617,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(UiIcons.help, color: UiColors.accent, size: 20),
|
||||||
UiIcons.help,
|
|
||||||
color: UiColors.accent,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -636,8 +640,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () =>
|
onTap: () => context.read<FormW4Cubit>().multipleJobsChanged(
|
||||||
context.read<FormW4Cubit>().multipleJobsChanged(!state.multipleJobs),
|
!state.multipleJobs,
|
||||||
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -654,10 +659,14 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: state.multipleJobs ? UiColors.primary : UiColors.bgPopup,
|
color: state.multipleJobs
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.bgPopup,
|
||||||
borderRadius: UiConstants.radiusMd,
|
borderRadius: UiConstants.radiusMd,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: state.multipleJobs ? UiColors.primary : UiColors.border,
|
color: state.multipleJobs
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.border,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: state.multipleJobs
|
child: state.multipleJobs
|
||||||
@@ -741,7 +750,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
i18n.fields.children_under_17,
|
i18n.fields.children_under_17,
|
||||||
i18n.fields.children_each,
|
i18n.fields.children_each,
|
||||||
(FormW4State s) => s.qualifyingChildren,
|
(FormW4State s) => s.qualifyingChildren,
|
||||||
(int val) => context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
|
(int val) =>
|
||||||
|
context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
|
||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
@@ -753,7 +763,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
i18n.fields.other_dependents,
|
i18n.fields.other_dependents,
|
||||||
i18n.fields.other_each,
|
i18n.fields.other_each,
|
||||||
(FormW4State s) => s.otherDependents,
|
(FormW4State s) => s.otherDependents,
|
||||||
(int val) => context.read<FormW4Cubit>().otherDependentsChanged(val),
|
(int val) =>
|
||||||
|
context.read<FormW4Cubit>().otherDependentsChanged(val),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -775,9 +786,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${_totalCredits(state)}',
|
'\$${_totalCredits(state)}',
|
||||||
style: UiTypography.body2b.textSuccess.copyWith(
|
style: UiTypography.body2b.textSuccess.copyWith(fontSize: 18),
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -802,22 +811,14 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(child: Text(label, style: UiTypography.body2m)),
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: UiTypography.body2m,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.tagSuccess,
|
color: UiColors.tagSuccess,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(badge, style: UiTypography.footnote2b.textSuccess),
|
||||||
badge,
|
|
||||||
style: UiTypography.footnote2b.textSuccess,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -839,10 +840,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildCircleBtn(
|
_buildCircleBtn(UiIcons.add, () => onChanged(value + 1)),
|
||||||
UiIcons.add,
|
|
||||||
() => onChanged(value + 1),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -881,7 +879,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.other_income,
|
i18n.fields.other_income,
|
||||||
value: state.otherIncome,
|
value: state.otherIncome,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().otherIncomeChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().otherIncomeChanged(val),
|
||||||
placeholder: i18n.fields.hints.zero,
|
placeholder: i18n.fields.hints.zero,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
@@ -896,7 +895,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.deductions,
|
i18n.fields.deductions,
|
||||||
value: state.deductions,
|
value: state.deductions,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().deductionsChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().deductionsChanged(val),
|
||||||
placeholder: i18n.fields.hints.zero,
|
placeholder: i18n.fields.hints.zero,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
@@ -911,7 +911,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
_buildTextField(
|
_buildTextField(
|
||||||
i18n.fields.extra_withholding,
|
i18n.fields.extra_withholding,
|
||||||
value: state.extraWithholding,
|
value: state.extraWithholding,
|
||||||
onChanged: (String val) => context.read<FormW4Cubit>().extraWithholdingChanged(val),
|
onChanged: (String val) =>
|
||||||
|
context.read<FormW4Cubit>().extraWithholdingChanged(val),
|
||||||
placeholder: i18n.fields.hints.zero,
|
placeholder: i18n.fields.hints.zero,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
@@ -1019,10 +1020,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
|
style: const TextStyle(fontFamily: 'Cursive', fontSize: 18),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Text(
|
Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary),
|
||||||
i18n.fields.date_label,
|
|
||||||
style: UiTypography.body3m.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -1050,10 +1048,7 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(label, style: UiTypography.body2r.textSecondary),
|
||||||
label,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: UiTypography.body2m.copyWith(
|
style: UiTypography.body2m.copyWith(
|
||||||
@@ -1066,7 +1061,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getFilingStatusLabel(String status) {
|
String _getFilingStatusLabel(String status) {
|
||||||
final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields;
|
final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.w4.fields;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'SINGLE':
|
case 'SINGLE':
|
||||||
return i18n.status_single;
|
return i18n.status_single;
|
||||||
@@ -1084,7 +1081,9 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
FormW4State state,
|
FormW4State state,
|
||||||
List<Map<String, String>> steps,
|
List<Map<String, String>> steps,
|
||||||
) {
|
) {
|
||||||
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4;
|
final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff_compliance.tax_forms.w4;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
@@ -1131,8 +1130,8 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (
|
onPressed:
|
||||||
_canProceed(state) &&
|
(_canProceed(state) &&
|
||||||
state.status != FormW4Status.submitting)
|
state.status != FormW4Status.submitting)
|
||||||
? () => _handleNext(context, state.currentStep)
|
? () => _handleNext(context, state.currentStep)
|
||||||
: null,
|
: null,
|
||||||
@@ -1167,7 +1166,11 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
),
|
),
|
||||||
if (state.currentStep < steps.length - 1) ...<Widget>[
|
if (state.currentStep < steps.length - 1) ...<Widget>[
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white),
|
const Icon(
|
||||||
|
UiIcons.arrowRight,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1179,5 +1182,3 @@ class _FormW4PageState extends State<FormW4Page> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../blocs/tax_forms/tax_forms_cubit.dart';
|
import '../blocs/tax_forms/tax_forms_cubit.dart';
|
||||||
import '../blocs/tax_forms/tax_forms_state.dart';
|
import '../blocs/tax_forms/tax_forms_state.dart';
|
||||||
|
|
||||||
@@ -13,39 +14,10 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: const UiAppBar(
|
||||||
backgroundColor: UiColors.primary,
|
title: 'Tax Documents',
|
||||||
elevation: 0,
|
subtitle: 'Complete required forms to start working',
|
||||||
leading: IconButton(
|
showBackButton: true,
|
||||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.bgPopup),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'Tax Documents',
|
|
||||||
style: UiTypography.headline3m.textSecondary,
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(24),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: UiConstants.space5,
|
|
||||||
right: UiConstants.space5,
|
|
||||||
bottom: UiConstants.space5,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Complete required forms to start working',
|
|
||||||
style: UiTypography.body3r.copyWith(
|
|
||||||
color: UiColors.primaryForeground.withValues(alpha: 0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: BlocProvider<TaxFormsCubit>(
|
body: BlocProvider<TaxFormsCubit>(
|
||||||
create: (BuildContext context) {
|
create: (BuildContext context) {
|
||||||
@@ -64,7 +36,9 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
if (state.status == TaxFormsStatus.failure) {
|
if (state.status == TaxFormsStatus.failure) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.errorMessage != null
|
state.errorMessage != null
|
||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
@@ -81,10 +55,12 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
vertical: UiConstants.space6,
|
vertical: UiConstants.space6,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
spacing: UiConstants.space6,
|
spacing: UiConstants.space4,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_buildProgressOverview(state.forms),
|
_buildProgressOverview(state.forms),
|
||||||
...state.forms.map((TaxForm form) => _buildFormCard(context, form)),
|
...state.forms.map(
|
||||||
|
(TaxForm form) => _buildFormCard(context, form),
|
||||||
|
),
|
||||||
_buildInfoCard(),
|
_buildInfoCard(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -118,10 +94,7 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text('Document Progress', style: UiTypography.body2m.textPrimary),
|
||||||
'Document Progress',
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
'$completedCount/$totalCount',
|
'$completedCount/$totalCount',
|
||||||
style: UiTypography.body2m.textSecondary,
|
style: UiTypography.body2m.textSecondary,
|
||||||
@@ -150,12 +123,18 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (form is I9TaxForm) {
|
if (form is I9TaxForm) {
|
||||||
final Object? result = await Modular.to.pushNamed('i9', arguments: form);
|
final Object? result = await Modular.to.pushNamed(
|
||||||
|
'i9',
|
||||||
|
arguments: form,
|
||||||
|
);
|
||||||
if (result == true && context.mounted) {
|
if (result == true && context.mounted) {
|
||||||
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
||||||
}
|
}
|
||||||
} else if (form is W4TaxForm) {
|
} else if (form is W4TaxForm) {
|
||||||
final Object? result = await Modular.to.pushNamed('w4', arguments: form);
|
final Object? result = await Modular.to.pushNamed(
|
||||||
|
'w4',
|
||||||
|
arguments: form,
|
||||||
|
);
|
||||||
if (result == true && context.mounted) {
|
if (result == true && context.mounted) {
|
||||||
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
||||||
}
|
}
|
||||||
@@ -245,10 +224,7 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
color: UiColors.textSuccess,
|
color: UiColors.textSuccess,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text('Completed', style: UiTypography.footnote2b.textSuccess),
|
||||||
'Completed',
|
|
||||||
style: UiTypography.footnote2b.textSuccess,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -267,10 +243,7 @@ class TaxFormsPage extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning),
|
const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text('In Progress', style: UiTypography.footnote2b.textWarning),
|
||||||
'In Progress',
|
|
||||||
style: UiTypography.footnote2b.textWarning,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
// ignore: depend_on_referenced_packages
|
|
||||||
|
|
||||||
import '../blocs/bank_account_cubit.dart';
|
import '../blocs/bank_account_cubit.dart';
|
||||||
import '../blocs/bank_account_state.dart';
|
import '../blocs/bank_account_state.dart';
|
||||||
|
import '../widgets/account_card.dart';
|
||||||
import '../widgets/add_account_form.dart';
|
import '../widgets/add_account_form.dart';
|
||||||
|
import '../widgets/security_notice.dart';
|
||||||
|
|
||||||
class BankAccountPage extends StatelessWidget {
|
class BankAccountPage extends StatelessWidget {
|
||||||
const BankAccountPage({super.key});
|
const BankAccountPage({super.key});
|
||||||
@@ -26,23 +28,7 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
final dynamic strings = t.staff.profile.bank_account_page;
|
final dynamic strings = t.staff.profile.bank_account_page;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background,
|
appBar: UiAppBar(title: strings.title, showBackButton: true),
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: UiColors.background, // Was surface
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.textSecondary),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
strings.title,
|
|
||||||
style: UiTypography.headline3m.textPrimary,
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(1.0),
|
|
||||||
child: Container(color: UiColors.border, height: 1.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: BlocConsumer<BankAccountCubit, BankAccountState>(
|
body: BlocConsumer<BankAccountCubit, BankAccountState>(
|
||||||
bloc: cubit,
|
bloc: cubit,
|
||||||
listener: (BuildContext context, BankAccountState state) {
|
listener: (BuildContext context, BankAccountState state) {
|
||||||
@@ -61,7 +47,8 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
// Error is already shown on the page itself (lines 73-85), no need for snackbar
|
// Error is already shown on the page itself (lines 73-85), no need for snackbar
|
||||||
},
|
},
|
||||||
builder: (BuildContext context, BankAccountState state) {
|
builder: (BuildContext context, BankAccountState state) {
|
||||||
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
|
if (state.status == BankAccountStatus.loading &&
|
||||||
|
state.accounts.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +61,9 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
: 'Error',
|
: 'Error',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
|
style: UiTypography.body1m.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -88,15 +77,22 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_buildSecurityNotice(strings),
|
SecurityNotice(strings: strings),
|
||||||
const SizedBox(height: UiConstants.space6),
|
if (state.accounts.isEmpty) ...<Widget>[
|
||||||
Text(
|
const SizedBox(height: UiConstants.space32),
|
||||||
strings.linked_accounts,
|
const UiEmptyState(
|
||||||
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary),
|
icon: UiIcons.building,
|
||||||
|
title: 'No accounts yet',
|
||||||
|
description:
|
||||||
|
'Add your first bank account to get started',
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
] else ...<Widget>[
|
||||||
...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
...state.accounts.map<Widget>(
|
||||||
|
(StaffBankAccount account) =>
|
||||||
|
AccountCard(account: account, strings: strings),
|
||||||
|
),
|
||||||
|
],
|
||||||
// Add extra padding at bottom
|
// Add extra padding at bottom
|
||||||
const SizedBox(height: UiConstants.space20),
|
const SizedBox(height: UiConstants.space20),
|
||||||
],
|
],
|
||||||
@@ -121,17 +117,23 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
backgroundColor: UiColors.transparent,
|
backgroundColor: UiColors.transparent,
|
||||||
child: AddAccountForm(
|
child: AddAccountForm(
|
||||||
strings: strings,
|
strings: strings,
|
||||||
onSubmit: (String bankName, String routing, String account, String type) {
|
onSubmit:
|
||||||
|
(
|
||||||
|
String bankName,
|
||||||
|
String routing,
|
||||||
|
String account,
|
||||||
|
String type,
|
||||||
|
) {
|
||||||
cubit.addAccount(
|
cubit.addAccount(
|
||||||
bankName: bankName,
|
bankName: bankName,
|
||||||
routingNumber: routing,
|
routingNumber: routing,
|
||||||
accountNumber: account,
|
accountNumber: account,
|
||||||
type: type,
|
type: type,
|
||||||
);
|
);
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
},
|
},
|
||||||
onCancel: () {
|
onCancel: () {
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -148,118 +150,4 @@ class BankAccountPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSecurityNotice(dynamic strings) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.shield, color: UiColors.primary, size: 20),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
strings.secure_title,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space1 - 2), // 2px
|
|
||||||
Text(
|
|
||||||
strings.secure_subtitle,
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAccountCard(StaffBankAccount account, dynamic strings) {
|
|
||||||
final bool isPrimary = account.isPrimary;
|
|
||||||
const Color primaryColor = UiColors.primary;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.bgPopup, // Was surface, using bgPopup (white) for card
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
border: Border.all(
|
|
||||||
color: isPrimary ? primaryColor : UiColors.border,
|
|
||||||
width: isPrimary ? 2 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primaryColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(
|
|
||||||
UiIcons.building,
|
|
||||||
color: primaryColor,
|
|
||||||
size: UiConstants.iconLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
account.bankName,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
strings.account_ending(
|
|
||||||
last4: account.last4?.isNotEmpty == true
|
|
||||||
? account.last4!
|
|
||||||
: '----',
|
|
||||||
),
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (isPrimary)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space2,
|
|
||||||
vertical: UiConstants.space1,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primaryColor.withValues(alpha: 0.15),
|
|
||||||
borderRadius: UiConstants.radiusFull,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.check, size: UiConstants.iconXs, color: primaryColor),
|
|
||||||
const SizedBox(width: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
strings.primary,
|
|
||||||
style: UiTypography.body3m.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import '../blocs/time_card_bloc.dart';
|
import '../blocs/time_card_bloc.dart';
|
||||||
import '../widgets/month_selector.dart';
|
import '../widgets/month_selector.dart';
|
||||||
import '../widgets/shift_history_list.dart';
|
import '../widgets/shift_history_list.dart';
|
||||||
@@ -18,11 +19,12 @@ class TimeCardPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TimeCardPageState extends State<TimeCardPage> {
|
class _TimeCardPageState extends State<TimeCardPage> {
|
||||||
final TimeCardBloc _bloc = Modular.get<TimeCardBloc>();
|
late final TimeCardBloc _bloc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_bloc = Modular.get<TimeCardBloc>();
|
||||||
_bloc.add(LoadTimeCards(DateTime.now()));
|
_bloc.add(LoadTimeCards(DateTime.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,22 +34,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
|
|||||||
return BlocProvider.value(
|
return BlocProvider.value(
|
||||||
value: _bloc,
|
value: _bloc,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: UiColors.bgPrimary,
|
appBar: UiAppBar(
|
||||||
appBar: AppBar(
|
title: t.staff_time_card.title,
|
||||||
backgroundColor: UiColors.bgPopup,
|
showBackButton: true,
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
t.staff_time_card.title,
|
|
||||||
style: UiTypography.headline4m.textPrimary,
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(1.0),
|
|
||||||
child: Container(color: UiColors.border, height: 1.0),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: BlocConsumer<TimeCardBloc, TimeCardState>(
|
body: BlocConsumer<TimeCardBloc, TimeCardState>(
|
||||||
listener: (BuildContext context, TimeCardState state) {
|
listener: (BuildContext context, TimeCardState state) {
|
||||||
@@ -69,7 +58,9 @@ class _TimeCardPageState extends State<TimeCardPage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
translateErrorKey(state.message),
|
translateErrorKey(state.message),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
|
style: UiTypography.body1m.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -83,12 +74,22 @@ class _TimeCardPageState extends State<TimeCardPage> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
MonthSelector(
|
MonthSelector(
|
||||||
selectedDate: state.selectedMonth,
|
selectedDate: state.selectedMonth,
|
||||||
onPreviousMonth: () => _bloc.add(ChangeMonth(
|
onPreviousMonth: () => _bloc.add(
|
||||||
DateTime(state.selectedMonth.year, state.selectedMonth.month - 1),
|
ChangeMonth(
|
||||||
)),
|
DateTime(
|
||||||
onNextMonth: () => _bloc.add(ChangeMonth(
|
state.selectedMonth.year,
|
||||||
DateTime(state.selectedMonth.year, state.selectedMonth.month + 1),
|
state.selectedMonth.month - 1,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onNextMonth: () => _bloc.add(
|
||||||
|
ChangeMonth(
|
||||||
|
DateTime(
|
||||||
|
state.selectedMonth.year,
|
||||||
|
state.selectedMonth.month + 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
TimeCardSummary(
|
TimeCardSummary(
|
||||||
@@ -108,4 +109,3 @@ class _TimeCardPageState extends State<TimeCardPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
|
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
|
||||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart';
|
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart';
|
||||||
|
|
||||||
|
import '../widgets/attire_capture_page/file_types_banner.dart';
|
||||||
import '../widgets/attire_capture_page/footer_section.dart';
|
import '../widgets/attire_capture_page/footer_section.dart';
|
||||||
import '../widgets/attire_capture_page/image_preview_section.dart';
|
import '../widgets/attire_capture_page/image_preview_section.dart';
|
||||||
import '../widgets/attire_capture_page/info_section.dart';
|
import '../widgets/attire_capture_page/info_section.dart';
|
||||||
@@ -135,7 +136,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
leading: const Icon(Icons.photo_library),
|
leading: const Icon(Icons.photo_library),
|
||||||
title: Text(t.common.gallery),
|
title: Text(t.common.gallery),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
_onGallery(context);
|
_onGallery(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -143,7 +144,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
leading: const Icon(Icons.camera_alt),
|
leading: const Icon(Icons.camera_alt),
|
||||||
title: Text(t.common.camera),
|
title: Text(t.common.camera),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Modular.to.pop();
|
Modular.to.popSafe();
|
||||||
_onCamera(context);
|
_onCamera(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -215,10 +216,16 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
|
|
||||||
String _getStatusText(bool hasUploadedPhoto) {
|
String _getStatusText(bool hasUploadedPhoto) {
|
||||||
return switch (widget.item.verificationStatus) {
|
return switch (widget.item.verificationStatus) {
|
||||||
AttireVerificationStatus.approved => t.staff_profile_attire.capture.approved,
|
AttireVerificationStatus.approved =>
|
||||||
AttireVerificationStatus.rejected => t.staff_profile_attire.capture.rejected,
|
t.staff_profile_attire.capture.approved,
|
||||||
AttireVerificationStatus.pending => t.staff_profile_attire.capture.pending_verification,
|
AttireVerificationStatus.rejected =>
|
||||||
_ => hasUploadedPhoto ? t.staff_profile_attire.capture.pending_verification : t.staff_profile_attire.capture.not_uploaded,
|
t.staff_profile_attire.capture.rejected,
|
||||||
|
AttireVerificationStatus.pending =>
|
||||||
|
t.staff_profile_attire.capture.pending_verification,
|
||||||
|
_ =>
|
||||||
|
hasUploadedPhoto
|
||||||
|
? t.staff_profile_attire.capture.pending_verification
|
||||||
|
: t.staff_profile_attire.capture.not_uploaded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +287,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_FileTypesBanner(
|
FileTypesBanner(
|
||||||
message: t.staff_profile_attire.upload_file_types_banner,
|
message: t
|
||||||
|
.staff_profile_attire
|
||||||
|
.upload_file_types_banner,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
ImagePreviewSection(
|
ImagePreviewSection(
|
||||||
@@ -327,43 +336,3 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Banner displaying accepted file types and size limit for attire upload.
|
|
||||||
class _FileTypesBanner extends StatelessWidget {
|
|
||||||
const _FileTypesBanner({required this.message});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space4,
|
|
||||||
vertical: UiConstants.space3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.tagActive,
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Icon(
|
|
||||||
UiIcons.info,
|
|
||||||
size: 20,
|
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import 'attire_upload_buttons.dart';
|
import 'attire_upload_buttons.dart';
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ class FooterSection extends StatelessWidget {
|
|||||||
text: 'Submit Image',
|
text: 'Submit Image',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (updatedItem != null) {
|
if (updatedItem != null) {
|
||||||
Modular.to.pop(updatedItem);
|
Modular.to.popSafe(updatedItem);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,35 +7,10 @@ class AttireInfoCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return UiNoticeBanner(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
icon: UiIcons.shirt,
|
||||||
decoration: BoxDecoration(
|
title: t.staff_profile_attire.info_card.title,
|
||||||
color: UiColors.primary.withValues(alpha: 0.08),
|
description: t.staff_profile_attire.info_card.description,
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.shirt, color: UiColors.primary, size: 24),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
t.staff_profile_attire.info_card.title,
|
|
||||||
style: UiTypography.body2m.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
t.staff_profile_attire.info_card.description,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
import '../blocs/emergency_contact_bloc.dart';
|
import '../blocs/emergency_contact_bloc.dart';
|
||||||
import '../widgets/emergency_contact_add_button.dart';
|
import '../widgets/emergency_contact_add_button.dart';
|
||||||
import '../widgets/emergency_contact_form_item.dart';
|
import '../widgets/emergency_contact_form_item.dart';
|
||||||
@@ -21,22 +22,11 @@ class EmergencyContactScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Translations.of(context); // Force rebuild on locale change
|
Translations.of(context); // Force rebuild on locale change
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: UiAppBar(
|
||||||
elevation: 0,
|
title: 'Emergency Contact',
|
||||||
leading: IconButton(
|
showBackButton: true,
|
||||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
|
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
body: BlocProvider<EmergencyContactBloc>(
|
||||||
'Emergency Contact',
|
|
||||||
style: UiTypography.title1m.textPrimary,
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(1.0),
|
|
||||||
child: Container(color: UiColors.border, height: 1.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: BlocProvider(
|
|
||||||
create: (context) => Modular.get<EmergencyContactBloc>(),
|
create: (context) => Modular.get<EmergencyContactBloc>(),
|
||||||
child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>(
|
child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
|
|||||||
@@ -6,16 +6,9 @@ class EmergencyContactInfoBanner extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return UiNoticeBanner(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
title:
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.accent.withValues(alpha: 0.2),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.',
|
'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.',
|
||||||
style: UiTypography.body2r.textPrimary,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user