diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 00000000..1a439740 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,248 @@ +name: Mobile CI + +on: + pull_request: + paths: + - 'apps/mobile/**' + - '.github/workflows/mobile-ci.yml' + push: + branches: + - main + paths: + - 'apps/mobile/**' + - '.github/workflows/mobile-ci.yml' + +jobs: + detect-changes: + name: ๐Ÿ” Detect Mobile Changes + runs-on: ubuntu-latest + outputs: + mobile-changed: ${{ steps.detect.outputs.mobile-changed }} + changed-files: ${{ steps.detect.outputs.changed-files }} + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐Ÿ”Ž Detect changes in apps/mobile + id: detect + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # For PR, compare all changes against base branch (not just latest commit) + # Using three-dot syntax (...) shows all files changed in the PR branch + BASE_REF="${{ github.event.pull_request.base.ref }}" + CHANGED_FILES=$(git diff --name-only origin/$BASE_REF...HEAD 2>/dev/null || echo "") + else + # For push, compare with previous commit + if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then + # Initial commit, check all files + CHANGED_FILES=$(git ls-tree -r --name-only HEAD) + else + CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }}) + fi + fi + + # Filter for files in apps/mobile + MOBILE_CHANGED=$(echo "$CHANGED_FILES" | grep -c "^apps/mobile/" || echo "0") + + if [[ $MOBILE_CHANGED -gt 0 ]]; then + echo "mobile-changed=true" >> $GITHUB_OUTPUT + # Get list of changed Dart files in apps/mobile + MOBILE_FILES=$(echo "$CHANGED_FILES" | grep "^apps/mobile/" | grep "\.dart$" || echo "") + echo "changed-files<> $GITHUB_OUTPUT + echo "$MOBILE_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "โœ… Changes detected in apps/mobile/" + echo "๐Ÿ“ Changed files:" + echo "$MOBILE_FILES" + else + echo "mobile-changed=false" >> $GITHUB_OUTPUT + echo "changed-files=" >> $GITHUB_OUTPUT + echo "โญ๏ธ No changes detected in apps/mobile/ - skipping checks" + fi + + compile: + name: ๐Ÿ—๏ธ Compile Mobile App + runs-on: macos-latest + needs: detect-changes + if: needs.detect-changes.outputs.mobile-changed == 'true' + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@v4 + + - name: ๐Ÿฆ‹ Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.x' + channel: 'stable' + cache: true + + - name: ๐Ÿ”ง Install Firebase CLI + run: | + npm install -g firebase-tools + + - name: ๐Ÿ“ฆ Get Flutter dependencies + run: | + make mobile-install + + - name: ๐Ÿ”จ Run compilation check + run: | + set -o pipefail + + echo "๐Ÿ—๏ธ Building client app for Android (dev mode)..." + if ! make mobile-client-build PLATFORM=apk MODE=debug 2>&1 | tee client_build.txt; then + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "โŒ CLIENT APP BUILD FAILED" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + exit 1 + fi + + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + + echo "๐Ÿ—๏ธ Building staff app for Android (dev mode)..." + if ! make mobile-staff-build PLATFORM=apk MODE=debug 2>&1 | tee staff_build.txt; then + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "โŒ STAFF APP BUILD FAILED" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + exit 1 + fi + + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "โœ… Build check PASSED - Both apps built successfully" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + + lint: + name: ๐Ÿงน Lint Changed Files + runs-on: macos-latest + needs: detect-changes + if: needs.detect-changes.outputs.mobile-changed == 'true' && needs.detect-changes.outputs.changed-files != '' + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@v4 + + - name: ๐Ÿฆ‹ Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.x' + channel: 'stable' + cache: true + + - name: ๐Ÿ”ง Install Firebase CLI + run: | + npm install -g firebase-tools + + - name: ๐Ÿ“ฆ Get Flutter dependencies + run: | + make mobile-install + + - name: ๐Ÿ” Lint changed Dart files + run: | + set -o pipefail + + # Get the list of changed files + CHANGED_FILES="${{ needs.detect-changes.outputs.changed-files }}" + + if [[ -z "$CHANGED_FILES" ]]; then + echo "โญ๏ธ No Dart files changed, skipping lint" + exit 0 + fi + + echo "๐ŸŽฏ Running lint on changed files:" + echo "$CHANGED_FILES" + echo "" + + # Run dart analyze on each changed file + HAS_ERRORS=false + FAILED_FILES=() + + while IFS= read -r file; do + if [[ -n "$file" && "$file" == *.dart && -f "$file" ]]; then + echo "๐Ÿ“ Analyzing: $file" + + if ! dart analyze "$file" 2>&1 | tee -a lint_output.txt; then + HAS_ERRORS=true + FAILED_FILES+=("$file") + fi + echo "" + fi + done <<< "$CHANGED_FILES" + + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + + # Check if there were any errors + if [[ "$HAS_ERRORS" == "true" ]]; then + echo "โŒ LINT ERRORS FOUND IN ${#FAILED_FILES[@]} FILE(S):" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + for file in "${FAILED_FILES[@]}"; do + echo " โŒ $file" + done + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "" + echo "See details above for each file" + exit 1 + else + echo "โœ… Lint check PASSED for all changed files" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + fi + + status-check: + name: ๐Ÿ“Š CI Status Check + runs-on: ubuntu-latest + needs: [detect-changes, compile, lint] + if: always() + steps: + - name: ๐Ÿ” Check mobile changes detected + run: | + if [[ "${{ needs.detect-changes.outputs.mobile-changed }}" == "true" ]]; then + echo "โœ… Mobile changes detected - running full checks" + else + echo "โญ๏ธ No mobile changes detected - skipping checks" + fi + + - name: ๐Ÿ—๏ธ Report compilation status + if: needs.detect-changes.outputs.mobile-changed == 'true' + run: | + if [[ "${{ needs.compile.result }}" == "success" ]]; then + echo "โœ… Compilation check: PASSED" + else + echo "โŒ Compilation check: FAILED" + exit 1 + fi + + - name: ๐Ÿงน Report lint status + if: needs.detect-changes.outputs.mobile-changed == 'true' && needs.detect-changes.outputs.changed-files != '' + run: | + if [[ "${{ needs.lint.result }}" == "success" ]]; then + echo "โœ… Lint check: PASSED" + else + echo "โŒ Lint check: FAILED" + exit 1 + fi + + - name: ๐ŸŽ‰ Final status + if: always() + run: | + echo "" + echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—" + echo "โ•‘ ๐Ÿ“Š Mobile CI Pipeline Summary โ•‘" + echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "" + echo "๐Ÿ” Change Detection: ${{ needs.detect-changes.result }}" + echo "๐Ÿ—๏ธ Compilation: ${{ needs.compile.result }}" + echo "๐Ÿงน Lint Check: ${{ needs.lint.result }}" + echo "" + + if [[ "${{ needs.detect-changes.result }}" != "success" || \ + ("${{ needs.detect-changes.outputs.mobile-changed }}" == "true" && \ + ("${{ needs.compile.result }}" != "success" || "${{ needs.lint.result }}" != "success")) ]]; then + echo "โŒ Pipeline FAILED" + exit 1 + else + echo "โœ… Pipeline PASSED" + fi + diff --git a/.gitignore b/.gitignore index 87b98195..e91fb146 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ lerna-debug.log* *.temp tmp/ temp/ +scripts/issues-to-create.md # ============================================================================== # SECURITY (CRITICAL) @@ -83,6 +84,8 @@ node_modules/ dist/ dist-ssr/ coverage/ +!**/lib/**/coverage/ +!**/src/**/coverage/ .nyc_output/ .vite/ .temp/ diff --git a/README.md b/README.md index 04116b6a..597a8a4b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ This project uses a modular `Makefile` for all common tasks. - **[03-contributing.md](./docs/03-contributing.md)**: Guidelines for new developers and setup checklist. - **[04-sync-prototypes.md](./docs/04-sync-prototypes.md)**: How to sync prototypes for local dev and AI context. +### Mobile Development Documentation +- **[MOBILE/01-architecture-principles.md](./docs/MOBILE/01-architecture-principles.md)**: Flutter clean architecture, package roles, and dependency flow. +- **[MOBILE/02-design-system-usage.md](./docs/MOBILE/02-design-system-usage.md)**: Design system components and theming guidelines. +- **[MOBILE/00-agent-development-rules.md](./docs/MOBILE/00-agent-development-rules.md)**: Rules and best practices for mobile development. + ## ๐Ÿค Contributing New to the team? Please read our **[Contributing Guide](./docs/03-contributing.md)** to get your environment set up and understand our workflow. diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index de98cbea..f3808646 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -30,6 +35,16 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 69b16696..8b0a7da5 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -24,6 +30,12 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -39,9 +51,11 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; } diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index a0e67c19..ddfa75aa 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:client_authentication/client_authentication.dart' as client_authentication; import 'package:client_create_order/client_create_order.dart' @@ -10,6 +12,7 @@ import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -20,7 +23,23 @@ import 'firebase_options.dart'; import 'src/widgets/session_listener.dart'; void main() async { - WidgetsFlutterBinding.ensureInitialized(); + final bool isFlutterTest = + !kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; + if (kDebugMode && !isFlutterTest) { + MarionetteBinding.ensureInitialized( + MarionetteConfiguration( + isInteractiveWidget: (Type type) => + type == UiButton || type == UiTextField, + extractText: (Widget widget) { + if (widget is UiTextField) return widget.label; + if (widget is UiButton) return widget.text; + return null; + }, + ), + ); + } else { + WidgetsFlutterBinding.ensureInitialized(); + } await Firebase.initializeApp( options: kIsWeb ? DefaultFirebaseOptions.currentPlatform : null, ); diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index c4ba9dcf..30780dc6 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -12,6 +14,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/client/maestro/README.md b/apps/mobile/apps/client/maestro/README.md new file mode 100644 index 00000000..97407ed3 --- /dev/null +++ b/apps/mobile/apps/client/maestro/README.md @@ -0,0 +1,42 @@ +# Maestro Integration Tests โ€” Client App + +Login and signup flows for the KROW Client app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report. +**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md) + +## Prerequisites + +- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed +- Client app built and installed on device/emulator: + ```bash + cd apps/mobile && flutter build apk + adb install build/app/outputs/flutter-apk/app-debug.apk + ``` + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +## Run + +From the project root: + +```bash +# Login +maestro test apps/mobile/apps/client/maestro/login.yaml + +# Signup +maestro test apps/mobile/apps/client/maestro/signup.yaml +``` + +## Flows + +| File | Flow | Description | +|------------|-------------|--------------------------------------------| +| login.yaml | Client Login| Get Started โ†’ Sign In โ†’ Home | +| signup.yaml| Client Signup| Get Started โ†’ Create Account โ†’ Home | diff --git a/apps/mobile/apps/client/maestro/login.yaml b/apps/mobile/apps/client/maestro/login.yaml new file mode 100644 index 00000000..6598a03f --- /dev/null +++ b/apps/mobile/apps/client/maestro/login.yaml @@ -0,0 +1,18 @@ +# Client App - Login Flow +# Prerequisites: App built and installed (debug or release) +# Run: maestro test apps/mobile/apps/client/maestro/login.yaml +# Test credentials: legendary@krowd.com / Demo2026! +# Note: Auth uses Firebase/Data Connect + +appId: com.krowwithus.client +--- +- launchApp +- assertVisible: "Sign In" +- tapOn: "Sign In" +- assertVisible: "Email" +- tapOn: "Email" +- inputText: "legendary@krowd.com" +- tapOn: "Password" +- inputText: "Demo2026!" +- tapOn: "Sign In" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/maestro/signup.yaml b/apps/mobile/apps/client/maestro/signup.yaml new file mode 100644 index 00000000..eba61eb0 --- /dev/null +++ b/apps/mobile/apps/client/maestro/signup.yaml @@ -0,0 +1,23 @@ +# Client App - Sign Up Flow +# Prerequisites: App built and installed +# Run: maestro test apps/mobile/apps/client/maestro/signup.yaml +# Use NEW credentials for signup (creates new account) +# Env: MAESTRO_CLIENT_EMAIL, MAESTRO_CLIENT_PASSWORD, MAESTRO_CLIENT_COMPANY + +appId: com.krowwithus.client +--- +- launchApp +- assertVisible: "Create Account" +- tapOn: "Create Account" +- assertVisible: "Company" +- tapOn: "Company" +- inputText: "${MAESTRO_CLIENT_COMPANY}" +- tapOn: "Email" +- inputText: "${MAESTRO_CLIENT_EMAIL}" +- tapOn: "Password" +- inputText: "${MAESTRO_CLIENT_PASSWORD}" +- tapOn: + text: "Confirm Password" +- inputText: "${MAESTRO_CLIENT_PASSWORD}" +- tapOn: "Create Account" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index e947f7b5..31c14ec3 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: client_hubs: path: ../../packages/features/client/hubs client_create_order: - path: ../../packages/features/client/create_order + path: ../../packages/features/client/orders/create_order krow_core: path: ../../packages/core @@ -42,6 +42,7 @@ dependencies: sdk: flutter firebase_core: ^4.4.0 krow_data_connect: ^0.0.1 + marionette_flutter: ^0.3.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 869eecae..3a3369d4 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index 7ba8383b..b9b24c8b 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core url_launcher_windows diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index ee04ee9a..fbdc8215 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -45,6 +50,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index 7a704337..e8a688bb 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -36,6 +42,12 @@ @import google_maps_flutter_ios; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -57,11 +69,13 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 1858e1bd..440dba19 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -1,21 +1,42 @@ +import 'dart:io' show Platform; + import 'package:core_localization/core_localization.dart' as core_localization; import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; -import 'package:krow_core/core.dart'; import 'src/widgets/session_listener.dart'; void main() async { - WidgetsFlutterBinding.ensureInitialized(); + final bool isFlutterTest = !kIsWeb + ? Platform.environment.containsKey('FLUTTER_TEST') + : false; + if (kDebugMode && !isFlutterTest) { + MarionetteBinding.ensureInitialized( + MarionetteConfiguration( + isInteractiveWidget: (Type type) => + type == UiButton || type == UiTextField, + extractText: (Widget widget) { + if (widget is UiTextField) return widget.label; + if (widget is UiButton) return widget.text; + return null; + }, + ), + ); + } else { + WidgetsFlutterBinding.ensureInitialized(); + } await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Register global BLoC observer for centralized error logging @@ -26,7 +47,10 @@ void main() async { // Initialize session listener for Firebase Auth state changes DataConnectService.instance.initializeAuthListener( - allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + allowedRoles: [ + 'STAFF', + 'BOTH', + ], // Only allow users with STAFF or BOTH roles ); runApp( @@ -40,7 +64,11 @@ void main() async { /// The main application module. class AppModule extends Module { @override - List get imports => [core_localization.LocalizationModule()]; + List get imports => [ + CoreModule(), + core_localization.LocalizationModule(), + staff_authentication.StaffAuthenticationModule(), + ]; @override void routes(RouteManager r) { diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index 258bd901..de44a5e8 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -40,7 +40,7 @@ class _SessionListenerState extends State { debugPrint('[SessionListener] Initialized session listener'); } - void _handleSessionChange(SessionState state) { + Future _handleSessionChange(SessionState state) async { if (!mounted) return; switch (state.type) { @@ -65,6 +65,7 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); + // Navigate to the main app Modular.to.toStaffHome(); break; diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index 83c9214f..56b4b1e5 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -13,6 +15,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/staff/maestro/README.md b/apps/mobile/apps/staff/maestro/README.md new file mode 100644 index 00000000..505faaec --- /dev/null +++ b/apps/mobile/apps/staff/maestro/README.md @@ -0,0 +1,41 @@ +# Maestro Integration Tests โ€” Staff App + +Login and signup flows for the KROW Staff app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report. +**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md) + +## Prerequisites + +- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed +- Staff app built and installed +- **Firebase test phone** in Firebase Console (Auth > Sign-in method > Phone): + - Login: +1 555-765-4321 / OTP 123456 + - Signup: add a different test number for new accounts + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +## Run + +From the project root: + +```bash +# Login +maestro test apps/mobile/apps/staff/maestro/login.yaml + +# Signup +maestro test apps/mobile/apps/staff/maestro/signup.yaml +``` + +## Flows + +| File | Flow | Description | +|------------|------------|-------------------------------------| +| login.yaml | Staff Login| Get Started โ†’ Log In โ†’ Phone โ†’ OTP โ†’ Home | +| signup.yaml| Staff Signup| Get Started โ†’ Sign Up โ†’ Phone โ†’ OTP โ†’ Profile Setup | diff --git a/apps/mobile/apps/staff/maestro/login.yaml b/apps/mobile/apps/staff/maestro/login.yaml new file mode 100644 index 00000000..aa0b21a1 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/login.yaml @@ -0,0 +1,18 @@ +# Staff App - Login Flow (Phone + OTP) +# Prerequisites: App built and installed; Firebase test phone configured +# Firebase test phone: +1 555-765-4321 / OTP 123456 +# Run: maestro test apps/mobile/apps/staff/maestro/login.yaml + +appId: com.krowwithus.staff +--- +- launchApp +- assertVisible: "Log In" +- tapOn: "Log In" +- assertVisible: "Send Code" +- inputText: "5557654321" +- tapOn: "Send Code" +# Wait for OTP screen +- assertVisible: "Continue" +- inputText: "123456" +- tapOn: "Continue" +# On success: staff main. Adjust final assertion to match staff home screen. diff --git a/apps/mobile/apps/staff/maestro/signup.yaml b/apps/mobile/apps/staff/maestro/signup.yaml new file mode 100644 index 00000000..e441e774 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/signup.yaml @@ -0,0 +1,18 @@ +# Staff App - Sign Up Flow (Phone + OTP) +# Prerequisites: App built and installed; Firebase test phone for NEW number +# Use a NEW phone number for signup (creates new account) +# Firebase: add test phone in Auth > Phone; e.g. +1 555-555-0000 / 123456 +# Run: maestro test apps/mobile/apps/staff/maestro/signup.yaml + +appId: com.krowwithus.staff +--- +- launchApp +- assertVisible: "Sign Up" +- tapOn: "Sign Up" +- assertVisible: "Send Code" +- inputText: "${MAESTRO_STAFF_SIGNUP_PHONE}" +- tapOn: "Send Code" +- assertVisible: "Continue" +- inputText: "123456" +- tapOn: "Continue" +# On success: Profile Setup. Adjust assertion to match destination. diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index d3b270ef..4019f01b 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: path: ../../packages/core krow_data_connect: path: ../../packages/data_connect + marionette_flutter: ^0.3.0 cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index 148eb231..f06cf63c 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 333a9eb4..e3928570 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core geolocator_windows diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index 95c65c67..a6d85eec 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,3 +1,4 @@ { - "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0", + "CORE_API_BASE_URL": "https://krow-core-api-e3g6witsvq-uc.a.run.app" } \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 3e53bf38..e5dff061 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -1,4 +1,6 @@ -library core; +library; + +export 'src/core_module.dart'; export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; @@ -8,3 +10,22 @@ export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/config/app_config.dart'; export 'src/routing/routing.dart'; +export 'src/services/api_service/api_service.dart'; +export 'src/services/api_service/dio_client.dart'; + +// Core API Services +export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_response.dart'; +export 'src/services/api_service/core_api_services/llm/llm_service.dart'; +export 'src/services/api_service/core_api_services/llm/llm_response.dart'; +export 'src/services/api_service/core_api_services/verification/verification_service.dart'; +export 'src/services/api_service/core_api_services/verification/verification_response.dart'; + +// Device Services +export 'src/services/device/camera/camera_service.dart'; +export 'src/services/device/gallery/gallery_service.dart'; +export 'src/services/device/file/file_picker_service.dart'; +export 'src/services/device/file_upload/device_file_upload_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 9bf56394..6752f3c6 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -5,5 +5,12 @@ class AppConfig { AppConfig._(); /// The Google Maps API key. - static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY'); + static const String googleMapsApiKey = String.fromEnvironment( + 'GOOGLE_MAPS_API_KEY', + ); + + /// The base URL for the Core API. + static const String coreApiBaseUrl = String.fromEnvironment( + 'CORE_API_BASE_URL', + ); } diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart new file mode 100644 index 00000000..bd782a8a --- /dev/null +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../core.dart'; + +/// A module that provides core services and shared dependencies. +/// +/// This module should be imported by the root [AppModule] to make +/// core services available globally as singletons. +class CoreModule extends Module { + @override + void exportedBinds(Injector i) { + // 1. Register the base HTTP client + i.addSingleton(() => DioClient()); + + // 2. Register the base API service + i.addSingleton(() => ApiService(i.get())); + + // 3. Register Core API Services (Orchestrators) + i.addSingleton( + () => FileUploadService(i.get()), + ); + i.addSingleton( + () => SignedUrlService(i.get()), + ); + i.addSingleton( + () => VerificationService(i.get()), + ); + i.addSingleton(() => LlmService(i.get())); + + // 4. Register Device dependency + i.addSingleton(() => ImagePicker()); + + // 5. Register Device Services + i.addSingleton(() => CameraService(i.get())); + i.addSingleton(() => GalleryService(i.get())); + i.addSingleton(FilePickerService.new); + i.addSingleton( + () => DeviceFileUploadService( + cameraService: i.get(), + galleryService: i.get(), + apiUploadService: i.get(), + ), + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/presentation/observers/core_bloc_observer.dart b/apps/mobile/packages/core/lib/src/presentation/observers/core_bloc_observer.dart index d9589916..4812be9b 100644 --- a/apps/mobile/packages/core/lib/src/presentation/observers/core_bloc_observer.dart +++ b/apps/mobile/packages/core/lib/src/presentation/observers/core_bloc_observer.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,16 +23,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// } /// ``` class CoreBlocObserver extends BlocObserver { - /// Whether to log state changes (can be verbose in production) - final bool logStateChanges; - - /// Whether to log events - final bool logEvents; CoreBlocObserver({ this.logStateChanges = false, this.logEvents = true, }); + /// Whether to log state changes (can be verbose in production) + final bool logStateChanges; + + /// Whether to log events + final bool logEvents; @override void onCreate(BlocBase bloc) { @@ -58,7 +59,7 @@ class CoreBlocObserver extends BlocObserver { super.onChange(bloc, change); if (logStateChanges) { developer.log( - 'State: ${change.currentState.runtimeType} โ†’ ${change.nextState.runtimeType}', + 'State: ${change.currentState.runtimeType} รขโ€ โ€™ ${change.nextState.runtimeType}', name: bloc.runtimeType.toString(), ); } @@ -108,9 +109,10 @@ class CoreBlocObserver extends BlocObserver { super.onTransition(bloc, transition); if (logStateChanges) { developer.log( - 'Transition: ${transition.event.runtimeType} โ†’ ${transition.nextState.runtimeType}', + 'Transition: ${transition.event.runtimeType} รขโ€ โ€™ ${transition.nextState.runtimeType}', name: bloc.runtimeType.toString(), ); } } } + diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index 4c7bcd34..a3650f69 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'route_paths.dart'; @@ -94,6 +95,21 @@ extension ClientNavigator on IModularNavigator { navigate(ClientPaths.billing); } + /// Navigates to the Completion Review page. + void toCompletionReview({Object? arguments}) { + pushNamed(ClientPaths.completionReview, arguments: arguments); + } + + /// Navigates to the full list of invoices awaiting approval. + void toAwaitingApproval({Object? arguments}) { + pushNamed(ClientPaths.awaitingApproval, arguments: arguments); + } + + /// Navigates to the Invoice Ready page. + void toInvoiceReady() { + pushNamed(ClientPaths.invoiceReady); + } + /// Navigates to the Orders tab. /// /// View and manage all shift orders with filtering and sorting. @@ -119,6 +135,11 @@ extension ClientNavigator on IModularNavigator { pushNamed(ClientPaths.settings); } + /// Pushes the edit profile page. + void toClientEditProfile() { + pushNamed('${ClientPaths.settings}/edit-profile'); + } + // ========================================================================== // HUBS MANAGEMENT // ========================================================================== @@ -130,6 +151,25 @@ extension ClientNavigator on IModularNavigator { await pushNamed(ClientPaths.hubs); } + /// Navigates to the details of a specific hub. + Future toHubDetails(Hub hub) { + return pushNamed( + ClientPaths.hubDetails, + arguments: {'hub': hub}, + ); + } + + /// Navigates to the page to add a new hub or edit an existing one. + Future toEditHub({Hub? hub}) async { + return pushNamed( + ClientPaths.editHub, + arguments: {'hub': hub}, + // Some versions of Modular allow passing opaque here, but if not + // we'll handle transparency in the page itself which we already do. + // To ensure it's not opaque, we'll use push with a PageRouteBuilder if needed. + ); + } + // ========================================================================== // ORDER CREATION // ========================================================================== @@ -137,35 +177,47 @@ extension ClientNavigator on IModularNavigator { /// Pushes the order creation flow entry page. /// /// This is the starting point for all order creation flows. - void toCreateOrder() { - pushNamed(ClientPaths.createOrder); + void toCreateOrder({Object? arguments}) { + navigate(ClientPaths.createOrder, arguments: arguments); } /// Pushes the rapid order creation flow. /// /// Quick shift creation with simplified inputs for urgent needs. - void toCreateOrderRapid() { - pushNamed(ClientPaths.createOrderRapid); + void toCreateOrderRapid({Object? arguments}) { + pushNamed(ClientPaths.createOrderRapid, arguments: arguments); } /// Pushes the one-time order creation flow. /// /// Create a shift that occurs once at a specific date and time. - void toCreateOrderOneTime() { - pushNamed(ClientPaths.createOrderOneTime); + void toCreateOrderOneTime({Object? arguments}) { + pushNamed(ClientPaths.createOrderOneTime, arguments: arguments); } /// Pushes the recurring order creation flow. /// /// Create shifts that repeat on a defined schedule (daily, weekly, etc.). - void toCreateOrderRecurring() { - pushNamed(ClientPaths.createOrderRecurring); + void toCreateOrderRecurring({Object? arguments}) { + pushNamed(ClientPaths.createOrderRecurring, arguments: arguments); } /// Pushes the permanent order creation flow. /// /// Create a long-term or permanent staffing position. - void toCreateOrderPermanent() { - pushNamed(ClientPaths.createOrderPermanent); + void toCreateOrderPermanent({Object? arguments}) { + pushNamed(ClientPaths.createOrderPermanent, arguments: arguments); + } + + // ========================================================================== + // VIEW ORDER + // ========================================================================== + + /// Navigates to the order details page to a specific date. + void toOrdersSpecificDate(DateTime date) { + navigate( + ClientPaths.orders, + arguments: {'initialDate': date}, + ); } } diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index 900bb545..7575229d 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -16,14 +16,14 @@ class ClientPaths { /// Generate child route based on the given route and parent route /// /// This is useful for creating nested routes within modules. - static String childRoute(String parent, String child) { + static String childRoute(String parent, String child) { final String childPath = child.replaceFirst(parent, ''); - + // check if the child path is empty if (childPath.isEmpty) { return '/'; - } - + } + // ensure the child path starts with a '/' if (!childPath.startsWith('/')) { return '/$childPath'; @@ -81,6 +81,17 @@ class ClientPaths { /// Access billing history, payment methods, and invoices. static const String billing = '/client-main/billing'; + /// Completion review page - review shift completion records. + static const String completionReview = + '/client-main/billing/completion-review'; + + /// Full list of invoices awaiting approval. + static const String awaitingApproval = + '/client-main/billing/awaiting-approval'; + + /// Invoice ready page - view status of approved invoices. + static const String invoiceReady = '/client-main/billing/invoice-ready'; + /// Orders tab - view and manage shift orders. /// /// List of all orders with filtering and status tracking. @@ -109,6 +120,12 @@ class ClientPaths { /// View and manage physical locations/hubs where staff are deployed. static const String hubs = '/client-hubs'; + /// Specific hub details. + static const String hubDetails = '/client-hubs/details'; + + /// Page for adding or editing a hub. + static const String editHub = '/client-hubs/edit'; + // ========================================================================== // ORDER CREATION & MANAGEMENT // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/routing.dart b/apps/mobile/packages/core/lib/src/routing/routing.dart index 1baace0c..5aa70e20 100644 --- a/apps/mobile/packages/core/lib/src/routing/routing.dart +++ b/apps/mobile/packages/core/lib/src/routing/routing.dart @@ -41,6 +41,7 @@ /// final homePath = ClientPaths.home; /// final shiftsPath = StaffPaths.shifts; /// ``` +library; export 'client/route_paths.dart'; export 'client/navigator.dart'; diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 1269484c..5d62480c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -29,7 +29,7 @@ extension StaffNavigator on IModularNavigator { // ========================================================================== /// Navigates to the root get started/authentication screen. - /// + /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toInitialPage() { @@ -37,7 +37,7 @@ extension StaffNavigator on IModularNavigator { } /// Navigates to the get started page. - /// + /// /// This is the landing page for unauthenticated users, offering login/signup options. void toGetStartedPage() { navigate(StaffPaths.getStarted); @@ -64,7 +64,7 @@ extension StaffNavigator on IModularNavigator { /// This is typically called after successful phone verification for new /// staff members. Uses pushReplacement to prevent going back to verification. void toProfileSetup() { - pushReplacementNamed(StaffPaths.profileSetup); + pushNamed(StaffPaths.profileSetup); } // ========================================================================== @@ -76,7 +76,12 @@ extension StaffNavigator on IModularNavigator { /// This is the main landing page for authenticated staff members. /// Displays shift cards, quick actions, and notifications. void toStaffHome() { - pushNamed(StaffPaths.home); + pushNamedAndRemoveUntil(StaffPaths.home, (_) => false); + } + + /// Navigates to the benefits overview page. + void toBenefits() { + pushNamed(StaffPaths.benefits); } /// Navigates to the staff main shell. @@ -84,7 +89,7 @@ extension StaffNavigator on IModularNavigator { /// This is the container with bottom navigation. Navigates to home tab /// by default. Usually you'd navigate to a specific tab instead. void toStaffMain() { - navigate('${StaffPaths.main}/home/'); + pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); } // ========================================================================== @@ -98,7 +103,11 @@ extension StaffNavigator on IModularNavigator { /// Parameters: /// * [selectedDate] - Optional date to pre-select in the shifts view /// * [initialTab] - Optional initial tab (via query parameter) - void toShifts({DateTime? selectedDate, String? initialTab}) { + void toShifts({ + DateTime? selectedDate, + String? initialTab, + bool? refreshAvailable, + }) { final Map args = {}; if (selectedDate != null) { args['selectedDate'] = selectedDate; @@ -106,31 +115,31 @@ extension StaffNavigator on IModularNavigator { if (initialTab != null) { args['initialTab'] = initialTab; } - navigate( - StaffPaths.shifts, - arguments: args.isEmpty ? null : args, - ); + if (refreshAvailable == true) { + args['refreshAvailable'] = true; + } + navigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args); } /// Navigates to the Payments tab. /// /// View payment history, earnings breakdown, and tax information. void toPayments() { - navigate(StaffPaths.payments); + pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false); } /// Navigates to the Clock In tab. /// /// Access time tracking interface for active shifts. void toClockIn() { - navigate(StaffPaths.clockIn); + pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false); } /// Navigates to the Profile tab. /// /// Manage personal information, documents, and preferences. void toProfile() { - navigate(StaffPaths.profile); + pushNamedAndRemoveUntil(StaffPaths.profile, (_) => false); } // ========================================================================== @@ -148,22 +157,7 @@ extension StaffNavigator on IModularNavigator { /// The shift object is passed as an argument and can be retrieved /// in the details page. void toShiftDetails(Shift shift) { - navigate( - StaffPaths.shiftDetails(shift.id), - arguments: shift, - ); - } - - /// Pushes the shift details page (alternative method). - /// - /// Same as [toShiftDetails] but using pushNamed instead of navigate. - /// Use this when you want to add the details page to the stack rather - /// than replacing the current route. - void pushShiftDetails(Shift shift) { - pushNamed( - StaffPaths.shiftDetails(shift.id), - arguments: shift, - ); + navigate(StaffPaths.shiftDetails(shift.id), arguments: shift); } // ========================================================================== @@ -177,6 +171,13 @@ extension StaffNavigator on IModularNavigator { pushNamed(StaffPaths.onboardingPersonalInfo); } + /// Pushes the preferred locations editing page. + /// + /// Allows staff to search and manage their preferred US work locations. + void toPreferredLocations() { + pushNamed(StaffPaths.preferredLocations); + } + /// Pushes the emergency contact page. /// /// Manage emergency contact details for safety purposes. @@ -195,7 +196,22 @@ extension StaffNavigator on IModularNavigator { /// /// Record sizing and appearance information for uniform allocation. void toAttire() { - pushNamed(StaffPaths.attire); + navigate(StaffPaths.attire); + } + + /// Pushes the attire capture page. + /// + /// Parameters: + /// * [item] - The attire item to capture + /// * [initialPhotoUrl] - Optional initial photo URL + void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { + navigate( + StaffPaths.attireCapture, + arguments: { + 'item': item, + 'initialPhotoUrl': initialPhotoUrl, + }, + ); } // ========================================================================== @@ -284,13 +300,38 @@ extension StaffNavigator on IModularNavigator { pushNamed(StaffPaths.faqs); } - /// Pushes the privacy and security settings page. + // ========================================================================== + // PRIVACY & SECURITY + // ========================================================================== + + /// Navigates to the privacy and security settings page. /// - /// Manage privacy preferences and security settings. - void toPrivacy() { - pushNamed(StaffPaths.privacy); + /// Manage privacy preferences including: + /// * Location sharing settings + /// * View terms of service + /// * View privacy policy + void toPrivacySecurity() { + pushNamed(StaffPaths.privacySecurity); } + /// Navigates to the Terms of Service page. + /// + /// Display the full terms of service document in a dedicated page view. + void toTermsOfService() { + pushNamed(StaffPaths.termsOfService); + } + + /// Navigates to the Privacy Policy page. + /// + /// Display the full privacy policy document in a dedicated page view. + void toPrivacyPolicy() { + pushNamed(StaffPaths.privacyPolicy); + } + + // ========================================================================== + // MESSAGING & COMMUNICATION + // ========================================================================== + /// Pushes the messages page (placeholder). /// /// Access internal messaging system. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 1b49991c..4929e1a0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -16,14 +16,14 @@ class StaffPaths { /// Generate child route based on the given route and parent route /// /// This is useful for creating nested routes within modules. - static String childRoute(String parent, String child) { + static String childRoute(String parent, String child) { final String childPath = child.replaceFirst(parent, ''); - + // check if the child path is empty if (childPath.isEmpty) { return '/'; - } - + } + // ensure the child path starts with a '/' if (!childPath.startsWith('/')) { return '/$childPath'; @@ -31,7 +31,7 @@ class StaffPaths { return childPath; } - + // ========================================================================== // AUTHENTICATION // ========================================================================== @@ -72,6 +72,9 @@ class StaffPaths { /// Displays shift cards, quick actions, and notifications. static const String home = '/worker-main/home/'; + /// Benefits overview page. + static const String benefits = '/worker-main/home/benefits'; + /// Shifts tab - view and manage shifts. /// /// Browse available shifts, accepted shifts, and shift history. @@ -107,8 +110,7 @@ class StaffPaths { /// Path format: `/worker-main/shift-details/{shiftId}` /// /// Example: `/worker-main/shift-details/shift123` - static String shiftDetails(String shiftId) => - '$shiftDetailsRoute/$shiftId'; + static String shiftDetails(String shiftId) => '$shiftDetailsRoute/$shiftId'; // ========================================================================== // ONBOARDING & PROFILE SECTIONS @@ -117,8 +119,23 @@ class StaffPaths { /// Personal information onboarding. /// /// Collect basic personal information during staff onboarding. - static const String onboardingPersonalInfo = - '/worker-main/onboarding/personal-info/'; + static const String onboardingPersonalInfo = '/worker-main/personal-info/'; + + // ========================================================================== + // PERSONAL INFORMATION & PREFERENCES + // ========================================================================== + + /// Language selection page. + /// + /// Allows staff to select their preferred language for the app interface. + static const String languageSelection = + '/worker-main/personal-info/language-selection/'; + + /// Preferred locations editing page. + /// + /// Allows staff to search and select their preferred US work locations. + static const String preferredLocations = + '/worker-main/personal-info/preferred-locations/'; /// Emergency contact information. /// @@ -135,6 +152,9 @@ class StaffPaths { /// Record sizing and appearance information for uniform allocation. static const String attire = '/worker-main/attire/'; + /// Attire capture page. + static const String attireCapture = '/worker-main/attire/capture/'; + // ========================================================================== // COMPLIANCE & DOCUMENTS // ========================================================================== @@ -203,10 +223,33 @@ class StaffPaths { static const String leaderboard = '/leaderboard'; /// FAQs - frequently asked questions. - static const String faqs = '/faqs'; + /// + /// Access to frequently asked questions about the staff application. + static const String faqs = '/worker-main/faqs/'; + + // ========================================================================== + // PRIVACY & SECURITY + // ========================================================================== /// Privacy and security settings. - static const String privacy = '/privacy'; + /// + /// Manage privacy preferences, location sharing, terms of service, + /// and privacy policy. + static const String privacySecurity = '/worker-main/privacy-security/'; + + /// Terms of Service page. + /// + /// Display the full terms of service document. + static const String termsOfService = '/worker-main/privacy-security/terms/'; + + /// Privacy Policy page. + /// + /// Display the full privacy policy document. + static const String privacyPolicy = '/worker-main/privacy-security/policy/'; + + // ========================================================================== + // MESSAGING & COMMUNICATION (Placeholders) + // ========================================================================== /// Messages - internal messaging system (placeholder). static const String messages = '/messages'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart new file mode 100644 index 00000000..db1119c9 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -0,0 +1,127 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A service that handles HTTP communication using the [Dio] client. +/// +/// This class provides a wrapper around [Dio]'s methods to handle +/// response parsing and error handling in a consistent way. +class ApiService implements BaseApiService { + /// Creates an [ApiService] with the given [Dio] instance. + ApiService(this._dio); + + /// The underlying [Dio] client used for network requests. + final Dio _dio; + + /// Performs a GET request to the specified [endpoint]. + @override + Future get( + String endpoint, { + Map? params, + }) async { + try { + final Response response = await _dio.get( + endpoint, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a POST request to the specified [endpoint]. + @override + Future post( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.post( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PUT request to the specified [endpoint]. + @override + Future put( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.put( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PATCH request to the specified [endpoint]. + @override + Future patch( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.patch( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Extracts [ApiResponse] from a successful [Response]. + ApiResponse _handleResponse(Response response) { + return ApiResponse( + code: response.statusCode?.toString() ?? '200', + message: response.data['message']?.toString() ?? 'Success', + data: response.data, + ); + } + + /// Extracts [ApiResponse] from a [DioException]. + ApiResponse _handleError(DioException e) { + if (e.response?.data is Map) { + final Map body = + e.response!.data as Map; + return ApiResponse( + code: + body['code']?.toString() ?? + e.response?.statusCode?.toString() ?? + 'error', + message: body['message']?.toString() ?? e.message ?? 'Error occurred', + data: body['data'], + errors: _parseErrors(body['errors']), + ); + } + return ApiResponse( + code: e.response?.statusCode?.toString() ?? 'error', + message: e.message ?? 'Unknown error', + errors: {'exception': e.type.toString()}, + ); + } + + /// Helper to parse the errors map from various possible formats. + Map _parseErrors(dynamic errors) { + if (errors is Map) { + return Map.from(errors); + } + return const {}; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart new file mode 100644 index 00000000..1c2a80cd --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart @@ -0,0 +1,33 @@ +import '../../../config/app_config.dart'; + +/// Constants for Core API endpoints. +class CoreApiEndpoints { + CoreApiEndpoints._(); + + /// The base URL for the Core API. + static const String baseUrl = AppConfig.coreApiBaseUrl; + + /// Upload a file. + static const String uploadFile = '$baseUrl/core/upload-file'; + + /// Create a signed URL for a file. + static const String createSignedUrl = '$baseUrl/core/create-signed-url'; + + /// Invoke a Large Language Model. + static const String invokeLlm = '$baseUrl/core/invoke-llm'; + + /// Root for verification operations. + static const String verifications = '$baseUrl/core/verifications'; + + /// Get status of a verification job. + static String verificationStatus(String id) => + '$baseUrl/core/verifications/$id'; + + /// Review a verification decision. + static String verificationReview(String id) => + '$baseUrl/core/verifications/$id/review'; + + /// Retry a verification job. + static String verificationRetry(String id) => + '$baseUrl/core/verifications/$id/retry'; +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart new file mode 100644 index 00000000..941fe01d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart @@ -0,0 +1,54 @@ +/// Response model for file upload operation. +class FileUploadResponse { + /// Creates a [FileUploadResponse]. + const FileUploadResponse({ + required this.fileUri, + required this.contentType, + required this.size, + required this.bucket, + required this.path, + this.requestId, + }); + + /// Factory to create [FileUploadResponse] from JSON. + factory FileUploadResponse.fromJson(Map json) { + return FileUploadResponse( + fileUri: json['fileUri'] as String, + contentType: json['contentType'] as String, + size: json['size'] as int, + bucket: json['bucket'] as String, + path: json['path'] as String, + requestId: json['requestId'] as String?, + ); + } + + /// The Cloud Storage URI of the uploaded file. + final String fileUri; + + /// The MIME type of the file. + final String contentType; + + /// The size of the file in bytes. + final int size; + + /// The bucket where the file was uploaded. + final String bucket; + + /// The path within the bucket. + final String path; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'fileUri': fileUri, + 'contentType': contentType, + 'size': size, + 'bucket': bucket, + 'path': path, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart new file mode 100644 index 00000000..09dc2854 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'file_upload_response.dart'; + +/// Service for uploading files to the Core API. +class FileUploadService extends BaseCoreService { + /// Creates a [FileUploadService]. + FileUploadService(super.api); + + /// Uploads a file with optional visibility and category. + /// + /// [filePath] is the local path to the file. + /// [visibility] can be [FileVisibility.public] or [FileVisibility.private]. + /// [category] is an optional metadata field. + Future uploadFile({ + required String filePath, + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + final ApiResponse res = await action(() async { + final FormData formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + 'visibility': visibility.value, + if (category != null) 'category': category, + }); + + return api.post(CoreApiEndpoints.uploadFile, data: formData); + }); + + if (res.code.startsWith('2')) { + return FileUploadResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart new file mode 100644 index 00000000..add3c331 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart @@ -0,0 +1,42 @@ +/// Response model for LLM invocation. +class LlmResponse { + /// Creates an [LlmResponse]. + const LlmResponse({ + required this.result, + required this.model, + required this.latencyMs, + this.requestId, + }); + + /// Factory to create [LlmResponse] from JSON. + factory LlmResponse.fromJson(Map json) { + return LlmResponse( + result: json['result'] as Map, + model: json['model'] as String, + latencyMs: json['latencyMs'] as int, + requestId: json['requestId'] as String?, + ); + } + + /// The JSON result returned by the model. + final Map result; + + /// The model name used for invocation. + final String model; + + /// Time taken for the request in milliseconds. + final int latencyMs; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'result': result, + 'model': model, + 'latencyMs': latencyMs, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart new file mode 100644 index 00000000..5bf6208d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -0,0 +1,38 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'llm_response.dart'; + +/// Service for invoking Large Language Models (LLM). +class LlmService extends BaseCoreService { + /// Creates an [LlmService]. + LlmService(super.api); + + /// Invokes the LLM with a [prompt] and optional [schema]. + /// + /// [prompt] is the text instruction for the model. + /// [responseJsonSchema] is an optional JSON schema to enforce structure. + /// [fileUrls] are optional URLs of files (images/PDFs) to include in context. + Future invokeLlm({ + required String prompt, + Map? responseJsonSchema, + List? fileUrls, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.invokeLlm, + data: { + 'prompt': prompt, + if (responseJsonSchema != null) + 'responseJsonSchema': responseJsonSchema, + if (fileUrls != null) 'fileUrls': fileUrls, + }, + ); + }); + + if (res.code.startsWith('2')) { + return LlmResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart new file mode 100644 index 00000000..bf286f07 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart @@ -0,0 +1,36 @@ +/// Response model for creating a signed URL. +class SignedUrlResponse { + /// Creates a [SignedUrlResponse]. + const SignedUrlResponse({ + required this.signedUrl, + required this.expiresAt, + this.requestId, + }); + + /// Factory to create [SignedUrlResponse] from JSON. + factory SignedUrlResponse.fromJson(Map json) { + return SignedUrlResponse( + signedUrl: json['signedUrl'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + requestId: json['requestId'] as String?, + ); + } + + /// The generated signed URL. + final String signedUrl; + + /// The timestamp when the URL expires. + final DateTime expiresAt; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'signedUrl': signedUrl, + 'expiresAt': expiresAt.toIso8601String(), + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart new file mode 100644 index 00000000..f25fea52 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -0,0 +1,34 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'signed_url_response.dart'; + +/// Service for creating signed URLs for Cloud Storage objects. +class SignedUrlService extends BaseCoreService { + /// Creates a [SignedUrlService]. + SignedUrlService(super.api); + + /// Creates a signed URL for a specific [fileUri]. + /// + /// [fileUri] should be in gs:// format. + /// [expiresInSeconds] must be <= 900. + Future createSignedUrl({ + required String fileUri, + int expiresInSeconds = 300, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.createSignedUrl, + data: { + 'fileUri': fileUri, + 'expiresInSeconds': expiresInSeconds, + }, + ); + }); + + if (res.code.startsWith('2')) { + return SignedUrlResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart new file mode 100644 index 00000000..38f2ba25 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart @@ -0,0 +1,90 @@ +/// Represents the possible statuses of a verification job. +enum VerificationStatus { + /// Job is created and waiting to be processed. + pending('PENDING'), + + /// Job is currently being processed by machine or human. + processing('PROCESSING'), + + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const VerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [VerificationStatus] from a string. + static VerificationStatus fromString(String value) { + return VerificationStatus.values.firstWhere( + (VerificationStatus e) => e.value == value, + orElse: () => VerificationStatus.error, + ); + } +} + +/// Response model for verification operations. +class VerificationResponse { + /// Creates a [VerificationResponse]. + const VerificationResponse({ + required this.verificationId, + required this.status, + this.type, + this.review, + this.requestId, + }); + + /// Factory to create [VerificationResponse] from JSON. + factory VerificationResponse.fromJson(Map json) { + return VerificationResponse( + verificationId: json['verificationId'] as String, + status: VerificationStatus.fromString(json['status'] as String), + type: json['type'] as String?, + review: json['review'] != null + ? json['review'] as Map + : null, + requestId: json['requestId'] as String?, + ); + } + + /// The unique ID of the verification job. + final String verificationId; + + /// Current status of the verification. + final VerificationStatus status; + + /// The type of verification (e.g., attire, government_id). + final String? type; + + /// Optional human review details. + final Map? review; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'verificationId': verificationId, + 'status': status.value, + 'type': type, + 'review': review, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart new file mode 100644 index 00000000..73390819 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -0,0 +1,94 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'verification_response.dart'; + +/// Service for handling async verification jobs. +class VerificationService extends BaseCoreService { + /// Creates a [VerificationService]. + VerificationService(super.api); + + /// Enqueues a new verification job. + /// + /// [type] can be 'attire', 'government_id', etc. + /// [subjectType] is usually 'worker'. + /// [fileUri] is the gs:// path of the uploaded file. + Future createVerification({ + required String type, + required String subjectType, + required String subjectId, + required String fileUri, + Map? rules, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verifications, + data: { + 'type': type, + 'subjectType': subjectType, + 'subjectId': subjectId, + 'fileUri': fileUri, + if (rules != null) 'rules': rules, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Polls the status of a specific verification. + Future getStatus(String verificationId) async { + final ApiResponse res = await action(() async { + return api.get(CoreApiEndpoints.verificationStatus(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Submits a manual review decision. + /// + /// [decision] should be 'APPROVED' or 'REJECTED'. + Future reviewVerification({ + required String verificationId, + required String decision, + String? note, + String? reasonCode, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verificationReview(verificationId), + data: { + 'decision': decision, + if (note != null) 'note': note, + if (reasonCode != null) 'reasonCode': reasonCode, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Retries a verification job that failed or needs re-processing. + Future retryVerification(String verificationId) async { + final ApiResponse res = await action(() async { + return api.post(CoreApiEndpoints.verificationRetry(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart new file mode 100644 index 00000000..e035ae18 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -0,0 +1,27 @@ +import 'package:dio/dio.dart'; +import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; + +/// A custom Dio client for the Krow project that includes basic configuration +/// and an [AuthInterceptor]. +class DioClient extends DioMixin implements Dio { + DioClient([BaseOptions? baseOptions]) { + options = + baseOptions ?? + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ); + + // Use the default adapter + httpClientAdapter = HttpClientAdapter(); + + // Add interceptors + interceptors.addAll([ + AuthInterceptor(), + LogInterceptor( + requestBody: true, + responseBody: true, + ), // Added for better debugging + ]); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart new file mode 100644 index 00000000..d6974e57 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// An interceptor that adds the Firebase Auth ID token to the Authorization header. +class AuthInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + try { + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } catch (e) { + rethrow; + } + } + return handler.next(options); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart new file mode 100644 index 00000000..c7317aa4 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for capturing photos and videos using the device camera. +class CameraService extends BaseDeviceService { + /// Creates a [CameraService]. + CameraService(ImagePicker picker) : _picker = picker; + + final ImagePicker _picker; + + /// Captures a photo using the camera. + /// + /// Returns the path to the captured image, or null if cancelled. + Future takePhoto() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart new file mode 100644 index 00000000..55321461 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart @@ -0,0 +1,22 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking files from the device filesystem. +class FilePickerService extends BaseDeviceService { + /// Creates a [FilePickerService]. + const FilePickerService(); + + /// Picks a single file from the device. + /// + /// Returns the path to the selected file, or null if cancelled. + Future pickFile({List? allowedExtensions}) async { + return action(() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: allowedExtensions != null ? FileType.custom : FileType.any, + allowedExtensions: allowedExtensions, + ); + + return result?.files.single.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart new file mode 100644 index 00000000..4fea7e77 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart @@ -0,0 +1,60 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../camera/camera_service.dart'; +import '../gallery/gallery_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_response.dart'; + +/// Orchestrator service that combines device picking and network uploading. +/// +/// This provides a simplified entry point for features to "pick and upload" +/// in a single call. +class DeviceFileUploadService extends BaseDeviceService { + /// Creates a [DeviceFileUploadService]. + DeviceFileUploadService({ + required this.cameraService, + required this.galleryService, + required this.apiUploadService, + }); + + final CameraService cameraService; + final GalleryService galleryService; + final FileUploadService apiUploadService; + + /// Captures a photo from the camera and uploads it immediately. + Future uploadFromCamera({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await cameraService.takePhoto(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } + + /// Picks an image from the gallery and uploads it immediately. + Future uploadFromGallery({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await galleryService.pickImage(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart new file mode 100644 index 00000000..7667e73d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking media from the device gallery. +class GalleryService extends BaseDeviceService { + /// Creates a [GalleryService]. + GalleryService(this._picker); + + final ImagePicker _picker; + + /// Picks an image from the gallery. + /// + /// Returns the path to the selected image, or null if cancelled. + Future pickImage() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 80bacabe..08ec902f 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -11,10 +11,18 @@ environment: dependencies: flutter: sdk: flutter - flutter_bloc: ^8.1.0 - design_system: - path: ../design_system - equatable: ^2.0.8 - flutter_modular: ^6.4.1 + + # internal packages krow_domain: path: ../domain + design_system: + path: ../design_system + + flutter_bloc: ^8.1.0 + equatable: ^2.0.8 + flutter_modular: ^6.4.1 + dio: ^5.9.1 + image_picker: ^1.1.2 + path_provider: ^2.1.3 + file_picker: ^8.1.7 + firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/core_localization/lib/src/bloc/locale_event.dart b/apps/mobile/packages/core_localization/lib/src/bloc/locale_event.dart index 52a57fbc..4fc5b3ce 100644 --- a/apps/mobile/packages/core_localization/lib/src/bloc/locale_event.dart +++ b/apps/mobile/packages/core_localization/lib/src/bloc/locale_event.dart @@ -8,11 +8,11 @@ sealed class LocaleEvent { /// Event triggered when the user wants to change the application locale. class ChangeLocale extends LocaleEvent { - /// The new locale to apply. - final Locale locale; /// Creates a [ChangeLocale] event. const ChangeLocale(this.locale); + /// The new locale to apply. + final Locale locale; } /// Event triggered to load the saved locale from persistent storage. diff --git a/apps/mobile/packages/core_localization/lib/src/data/datasources/locale_local_data_source.dart b/apps/mobile/packages/core_localization/lib/src/data/datasources/locale_local_data_source.dart index f036b915..f53ff9dd 100644 --- a/apps/mobile/packages/core_localization/lib/src/data/datasources/locale_local_data_source.dart +++ b/apps/mobile/packages/core_localization/lib/src/data/datasources/locale_local_data_source.dart @@ -11,11 +11,11 @@ abstract interface class LocaleLocalDataSource { /// Implementation of [LocaleLocalDataSource] using [SharedPreferencesAsync]. class LocaleLocalDataSourceImpl implements LocaleLocalDataSource { - static const String _localeKey = 'app_locale'; - final SharedPreferencesAsync _sharedPreferences; /// Creates a [LocaleLocalDataSourceImpl] with the required [SharedPreferencesAsync] instance. LocaleLocalDataSourceImpl(this._sharedPreferences); + static const String _localeKey = 'app_locale'; + final SharedPreferencesAsync _sharedPreferences; @override Future saveLanguageCode(String languageCode) async { diff --git a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_default_locale_use_case.dart b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_default_locale_use_case.dart index e416d1cd..d526ef8d 100644 --- a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_default_locale_use_case.dart +++ b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_default_locale_use_case.dart @@ -3,10 +3,10 @@ import '../repositories/locale_repository_interface.dart'; /// Use case to retrieve the default locale. class GetDefaultLocaleUseCase { - final LocaleRepositoryInterface _repository; /// Creates a [GetDefaultLocaleUseCase] with the required [LocaleRepositoryInterface]. GetDefaultLocaleUseCase(this._repository); + final LocaleRepositoryInterface _repository; /// Retrieves the default locale. Locale call() { diff --git a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_locale_use_case.dart b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_locale_use_case.dart index 02256a69..4df1939e 100644 --- a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_locale_use_case.dart +++ b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_locale_use_case.dart @@ -7,10 +7,10 @@ import '../repositories/locale_repository_interface.dart'; /// This class extends [NoInputUseCase] and interacts with [LocaleRepositoryInterface] /// to fetch the saved locale. class GetLocaleUseCase extends NoInputUseCase { - final LocaleRepositoryInterface _repository; /// Creates a [GetLocaleUseCase] with the required [LocaleRepositoryInterface]. GetLocaleUseCase(this._repository); + final LocaleRepositoryInterface _repository; @override Future call() { diff --git a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_supported_locales_use_case.dart b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_supported_locales_use_case.dart index 8840b196..01c2b6ed 100644 --- a/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_supported_locales_use_case.dart +++ b/apps/mobile/packages/core_localization/lib/src/domain/usecases/get_supported_locales_use_case.dart @@ -3,10 +3,10 @@ import '../repositories/locale_repository_interface.dart'; /// Use case to retrieve the list of supported locales. class GetSupportedLocalesUseCase { - final LocaleRepositoryInterface _repository; /// Creates a [GetSupportedLocalesUseCase] with the required [LocaleRepositoryInterface]. GetSupportedLocalesUseCase(this._repository); + final LocaleRepositoryInterface _repository; /// Retrieves the supported locales. List call() { diff --git a/apps/mobile/packages/core_localization/lib/src/domain/usecases/set_locale_use_case.dart b/apps/mobile/packages/core_localization/lib/src/domain/usecases/set_locale_use_case.dart index dcddd0c1..f6e29b05 100644 --- a/apps/mobile/packages/core_localization/lib/src/domain/usecases/set_locale_use_case.dart +++ b/apps/mobile/packages/core_localization/lib/src/domain/usecases/set_locale_use_case.dart @@ -7,10 +7,10 @@ import '../repositories/locale_repository_interface.dart'; /// This class extends [UseCase] and interacts with [LocaleRepositoryInterface] /// to save a given locale. class SetLocaleUseCase extends UseCase { - final LocaleRepositoryInterface _repository; /// Creates a [SetLocaleUseCase] with the required [LocaleRepositoryInterface]. SetLocaleUseCase(this._repository); + final LocaleRepositoryInterface _repository; @override Future call(Locale input) { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 0241ab37..bd3e4341 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -104,7 +104,7 @@ "client_authentication": { "get_started_page": { "title": "Take Control of Your\nShifts and Events", - "subtitle": "Streamline your operations with powerful tools to manage schedules, track performance, and keep your team on the same pageโ€”all in one place", + "subtitle": "Streamline your operations with powerful tools to manage schedules, track performance, and keep your team on the same page\u2014all in one place", "sign_in_button": "Sign In", "create_account_button": "Create Account" }, @@ -208,9 +208,25 @@ "edit_profile": "Edit Profile", "hubs": "Hubs", "log_out": "Log Out", + "log_out_confirmation": "Are you sure you want to log out?", "quick_links": "Quick Links", "clock_in_hubs": "Clock-In Hubs", "billing_payments": "Billing & Payments" + }, + "preferences": { + "title": "PREFERENCES", + "push": "Push Notifications", + "email": "Email Notifications", + "sms": "SMS Notifications" + }, + "edit_profile": { + "title": "Edit Profile", + "first_name": "FIRST NAME", + "last_name": "LAST NAME", + "email": "EMAIL ADDRESS", + "phone": "PHONE NUMBER", + "save_button": "Save Changes", + "success_message": "Profile updated successfully" } }, "client_hubs": { @@ -237,8 +253,40 @@ "location_hint": "e.g., Downtown Restaurant", "address_label": "Address", "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", + "address_required": "Address is required", "create_button": "Create Hub" }, + "edit_hub": { + "title": "Edit Hub", + "subtitle": "Update hub details", + "name_label": "Hub Name *", + "name_hint": "e.g., Main Kitchen, Front Desk", + "address_label": "Address", + "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", + "save_button": "Save Changes", + "success": "Hub updated successfully!", + "created_success": "Hub created successfully", + "updated_success": "Hub updated successfully" + }, + "hub_details": { + "title": "Hub Details", + "name_label": "Name", + "address_label": "Address", + "nfc_label": "NFC Tag", + "nfc_not_assigned": "Not Assigned", + "cost_center_label": "Cost Center", + "cost_center_none": "Not Assigned", + "edit_button": "Edit Hub", + "deleted_success": "Hub deleted successfully" + }, "nfc_dialog": { "title": "Identify NFC Tag", "instruction": "Tap your phone to the NFC tag to identify it", @@ -293,6 +341,11 @@ "date_hint": "Select date", "location_label": "Location", "location_hint": "Enter address", + "hub_manager_label": "Shift Contact", + "hub_manager_desc": "On-site manager or supervisor for this shift", + "hub_manager_hint": "Select Contact", + "hub_manager_empty": "No hub managers available", + "hub_manager_none": "None", "positions_title": "Positions", "add_position": "Add Position", "position_number": "Position $number", @@ -344,6 +397,41 @@ "active": "Active", "completed": "Completed" }, + "order_edit_sheet": { + "title": "Edit Your Order", + "vendor_section": "VENDOR", + "location_section": "LOCATION", + "shift_contact_section": "SHIFT CONTACT", + "shift_contact_desc": "On-site manager or supervisor for this shift", + "select_contact": "Select Contact", + "no_hub_managers": "No hub managers available", + "none": "None", + "positions_section": "POSITIONS", + "add_position": "Add Position", + "review_positions": "Review $count Positions", + "order_name_hint": "Order name", + "remove": "Remove", + "select_role_hint": "Select role", + "start_label": "Start", + "end_label": "End", + "workers_label": "Workers", + "different_location": "Use different location for this position", + "different_location_title": "Different Location", + "enter_address_hint": "Enter different address", + "no_break": "No Break", + "positions": "Positions", + "workers": "Workers", + "est_cost": "Est. Cost", + "positions_breakdown": "Positions Breakdown", + "edit_button": "Edit", + "confirm_save": "Confirm & Save", + "position_singular": "Position", + "order_updated_title": "Order Updated!", + "order_updated_message": "Your shift has been updated successfully.", + "back_to_orders": "Back to Orders", + "one_time_order_title": "One-Time Order", + "refine_subtitle": "Refine your staffing needs" + }, "card": { "open": "OPEN", "filled": "FILLED", @@ -389,14 +477,59 @@ "month": "Month", "total": "Total", "hours": "$count hours", + "export_button": "Export All Invoices", "rate_optimization_title": "Rate Optimization", - "rate_optimization_body": "Save $amount/month by switching 3 shifts", + "rate_optimization_save": "Save ", + "rate_optimization_amount": "$amount/month", + "rate_optimization_shifts": " by switching 3 shifts", "view_details": "View Details", + "no_invoices_period": "No Invoices for the selected period", + "invoices_ready_title": "Invoices Ready", + "invoices_ready_subtitle": "You have approved items ready for payment.", + "retry": "Retry", + "error_occurred": "An error occurred", "invoice_history": "Invoice History", "view_all": "View all", - "export_button": "Export All Invoices", + "approved_success": "Invoice approved and payment initiated", + "flagged_success": "Invoice flagged for review", "pending_badge": "PENDING APPROVAL", - "paid_badge": "PAID" + "paid_badge": "PAID", + "all_caught_up": "All caught up!", + "no_pending_invoices": "No invoices awaiting approval", + "review_and_approve": "Review & Approve", + "review_and_approve_subtitle": "Review and approve for payment", + "invoice_ready": "Invoice Ready", + "total_amount_label": "Total Amount", + "hours_suffix": "hours", + "avg_rate_suffix": "/hr avg", + "stats": { + "total": "Total", + "workers": "workers", + "hrs": "HRS" + }, + "workers_tab": { + "title": "Workers ($count)", + "search_hint": "Search workers...", + "needs_review": "Needs Review ($count)", + "all": "All ($count)", + "min_break": "min break" + }, + "actions": { + "approve_pay": "Approve & Process Payment", + "flag_review": "Flag for Review", + "download_pdf": "Download Invoice PDF" + }, + "flag_dialog": { + "title": "Flag for Review", + "hint": "Describe the issue...", + "button": "Flag" + }, + "timesheets": { + "title": "Timesheets", + "approve_button": "Approve", + "decline_button": "Decline", + "approved_message": "Timesheet approved" + } }, "staff": { "main": { @@ -434,7 +567,7 @@ }, "empty_states": { "no_shifts_today": "No shifts scheduled for today", - "find_shifts_cta": "Find shifts โ†’", + "find_shifts_cta": "Find shifts \u2192", "no_shifts_tomorrow": "No shifts for tomorrow", "no_recommended_shifts": "No recommended shifts" }, @@ -444,7 +577,7 @@ "amount": "$amount" }, "recommended_card": { - "act_now": "โ€ข ACT NOW", + "act_now": "\u2022 ACT NOW", "one_day": "One Day", "today": "Today", "applied_for": "Applied for $title", @@ -458,6 +591,21 @@ "sick_days": "Sick Days", "vacation": "Vacation", "holidays": "Holidays" + }, + "overview": { + "title": "Your Benefits Overview", + "subtitle": "Manage and track your earned benefits here", + "request_payment": "Request Payment for $benefit", + "request_submitted": "Request submitted for $benefit", + "sick_leave_subtitle": "You need at least 8 hours to request sick leave", + "vacation_subtitle": "You need 40 hours to claim vacation pay", + "holidays_subtitle": "Pay holidays: Thanksgiving, Christmas, New Year", + "sick_leave_history": "SICK LEAVE HISTORY", + "compliance_banner": "Listed certificates are mandatory for employees. If the employee does not have the complete certificates, they can't proceed with their registration.", + "status": { + "pending": "Pending", + "submitted": "Submitted" + } } }, "auto_match": { @@ -521,7 +669,8 @@ "compliance": "COMPLIANCE", "level_up": "LEVEL UP", "finance": "FINANCE", - "support": "SUPPORT" + "support": "SUPPORT", + "settings": "SETTINGS" }, "menu_items": { "personal_info": "Personal Info", @@ -543,7 +692,8 @@ "timecard": "Timecard", "faqs": "FAQs", "privacy_security": "Privacy & Security", - "messages": "Messages" + "messages": "Messages", + "language": "Language" }, "bank_account_page": { "title": "Bank Account", @@ -585,8 +735,21 @@ "languages_hint": "English, Spanish, French...", "locations_label": "Preferred Locations", "locations_hint": "Downtown, Midtown, Brooklyn...", + "locations_summary_none": "Not set", "save_button": "Save Changes", - "save_success": "Personal info saved successfully" + "save_success": "Personal info saved successfully", + "preferred_locations": { + "title": "Preferred Locations", + "description": "Choose up to 5 locations in the US where you prefer to work. We'll prioritize shifts near these areas.", + "search_hint": "Search a city or area...", + "added_label": "YOUR LOCATIONS", + "max_reached": "You've reached the maximum of 5 locations", + "min_hint": "Add at least 1 preferred location", + "save_button": "Save Locations", + "save_success": "Preferred locations saved", + "remove_tooltip": "Remove location", + "empty_state": "No locations added yet.\nSearch above to add your preferred work areas." + } }, "experience": { "title": "Experience & Skills", @@ -639,6 +802,12 @@ "accept_shift_cta": "Accept a shift to clock in", "soon": "soon", "checked_in_at_label": "Checked in at", + "not_in_range": "You must be within $distance m to clock in.", + "location_verifying": "Verifying location...", + "attire_photo_label": "Attire Photo", + "take_attire_photo": "Take Photo", + "attire_photo_desc": "Take a photo of your attire for verification.", + "attire_captured": "Attire photo captured!", "nfc_dialog": { "scan_title": "NFC Scan Required", "scanned_title": "NFC Scanned", @@ -662,7 +831,7 @@ "eta_label": "$min min", "locked_desc": "Most app features are locked while commute mode is on. You'll be able to clock in once you arrive.", "turn_off": "Turn Off Commute Mode", - "arrived_title": "You've Arrived! ๐ŸŽ‰", + "arrived_title": "You've Arrived! \ud83c\udf89", "arrived_desc": "You're at the shift location. Ready to clock in?" }, "swipe": { @@ -925,7 +1094,7 @@ } }, "staff_profile_attire": { - "title": "Attire", + "title": "Verify Attire", "info_card": { "title": "Your Wardrobe", "description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe." @@ -934,16 +1103,16 @@ "required": "REQUIRED", "add_photo": "Add Photo", "added": "Added", - "pending": "โณ Pending verification" + "pending": "\u23f3 Pending verification" }, "attestation": "I certify that I own these items and will wear them to my shifts. I understand that items are pending manager verification at my first shift.", "actions": { "save": "Save Attire" }, "validation": { - "select_required": "โœ“ Select all required items", - "upload_required": "โœ“ Upload photos of required items", - "accept_attestation": "โœ“ Accept attestation" + "select_required": "\u2713 Select all required items", + "upload_required": "\u2713 Upload photos of required items", + "accept_attestation": "\u2713 Accept attestation" } }, "staff_shifts": { @@ -1062,8 +1231,23 @@ }, "card": { "cancelled": "CANCELLED", - "compensation": "โ€ข 4hr compensation" + "compensation": "\u2022 4hr compensation" } + }, + "find_shifts": { + "search_hint": "Search jobs, location...", + "filter_all": "All Jobs", + "filter_one_day": "One Day", + "filter_multi_day": "Multi-Day", + "filter_long_term": "Long Term", + "no_jobs_title": "No jobs available", + "no_jobs_subtitle": "Check back later", + "application_submitted": "Shift application submitted!", + "radius_filter_title": "Radius Filter", + "unlimited_distance": "Unlimited distance", + "within_miles": "Within $miles miles", + "clear": "Clear", + "apply": "Apply" } }, "staff_time_card": { @@ -1125,9 +1309,34 @@ "service_unavailable": "Service is currently unavailable." } }, + "staff_privacy_security": { + "title": "Privacy & Security", + "privacy_section": "Privacy", + "legal_section": "Legal", + "profile_visibility": { + "title": "Profile Visibility", + "subtitle": "Let clients see your profile" + }, + "terms_of_service": { + "title": "Terms of Service" + }, + "privacy_policy": { + "title": "Privacy Policy" + }, + "success": { + "profile_visibility_updated": "Profile visibility updated successfully!" + } + }, + "staff_faqs": { + "title": "FAQs", + "search_placeholder": "Search questions...", + "no_results": "No matching questions found", + "contact_support": "Contact Support" + }, "success": { "hub": { "created": "Hub created successfully!", + "updated": "Hub updated successfully!", "deleted": "Hub deleted successfully!", "nfc_assigned": "NFC tag assigned successfully!" }, @@ -1140,5 +1349,245 @@ "availability": { "updated": "Availability updated successfully" } + }, + "client_reports": { + "title": "Workforce Control Tower", + "tabs": { + "today": "Today", + "week": "Week", + "month": "Month", + "quarter": "Quarter" + }, + "metrics": { + "total_hrs": { + "label": "Total Hrs", + "badge": "This period" + }, + "ot_hours": { + "label": "OT Hours", + "badge": "5.1% of total" + }, + "total_spend": { + "label": "Total Spend", + "badge": "\u2193 8% vs last week" + }, + "fill_rate": { + "label": "Fill Rate", + "badge": "\u2191 2% improvement" + }, + "avg_fill_time": { + "label": "Avg Fill Time", + "badge": "Industry best" + }, + "no_show_rate": { + "label": "No-Show Rate", + "badge": "Below avg" + } + }, + "quick_reports": { + "title": "Quick Reports", + "export_all": "Export All", + "two_click_export": "2-click export", + "cards": { + "daily_ops": "Daily Ops Report", + "spend": "Spend Report", + "coverage": "Coverage Report", + "no_show": "No-Show Report", + "forecast": "Forecast Report", + "performance": "Performance Report" + } + }, + "daily_ops_report": { + "title": "Daily Ops Report", + "subtitle": "Real-time shift tracking", + "metrics": { + "scheduled": { + "label": "Scheduled", + "sub_value": "shifts" + }, + "workers": { + "label": "Workers", + "sub_value": "confirmed" + }, + "in_progress": { + "label": "In Progress", + "sub_value": "active now" + }, + "completed": { + "label": "Completed", + "sub_value": "done today" + } + }, + "all_shifts_title": "ALL SHIFTS", + "no_shifts_today": "No shifts scheduled for today", + "shift_item": { + "time": "Time", + "workers": "Workers", + "rate": "Rate" + }, + "statuses": { + "processing": "Processing", + "filling": "Filling", + "confirmed": "Confirmed", + "completed": "Completed" + }, + "placeholders": { + "export_message": "Exporting Daily Operations Report (Placeholder)" + } + }, + "spend_report": { + "title": "Spend Report", + "subtitle": "Cost analysis & breakdown", + "summary": { + "total_spend": "Total Spend", + "avg_daily": "Avg Daily", + "this_week": "This week", + "per_day": "Per day" + }, + "chart_title": "Daily Spend Trend", + "charts": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, + "spend_by_industry": "Spend by Industry", + "no_industry_data": "No industry data available", + "industries": { + "hospitality": "Hospitality", + "events": "Events", + "retail": "Retail" + }, + "percent_total": "$percent% of total", + "placeholders": { + "export_message": "Exporting Spend Report (Placeholder)" + } + }, + "forecast_report": { + "title": "Forecast Report", + "subtitle": "Next 4 weeks projection", + "metrics": { + "four_week_forecast": "4-Week Forecast", + "avg_weekly": "Avg Weekly", + "total_shifts": "Total Shifts", + "total_hours": "Total Hours" + }, + "badges": { + "total_projected": "Total projected", + "per_week": "Per week", + "scheduled": "Scheduled", + "worker_hours": "Worker hours" + }, + "chart_title": "Spending Forecast", + "weekly_breakdown": { + "title": "WEEKLY BREAKDOWN", + "week": "Week $index", + "shifts": "Shifts", + "hours": "Hours", + "avg_shift": "Avg/Shift" + }, + "buttons": { + "export": "Export" + }, + "empty_state": "No projections available", + "placeholders": { + "export_message": "Exporting Forecast Report (Placeholder)" + } + }, + "performance_report": { + "title": "Performance Report", + "subtitle": "Key metrics & benchmarks", + "overall_score": { + "title": "Overall Performance Score", + "excellent": "Excellent", + "good": "Good", + "needs_work": "Needs Work" + }, + "kpis_title": "KEY PERFORMANCE INDICATORS", + "kpis": { + "fill_rate": "Fill Rate", + "completion_rate": "Completion Rate", + "on_time_rate": "On-Time Rate", + "avg_fill_time": "Avg Fill Time", + "target_prefix": "Target: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", + "met": "\u2713 Met", + "close": "\u2192 Close", + "miss": "\u2717 Miss" + }, + "additional_metrics_title": "ADDITIONAL METRICS", + "additional_metrics": { + "total_shifts": "Total Shifts", + "no_show_rate": "No-Show Rate", + "worker_pool": "Worker Pool", + "avg_rating": "Avg Rating" + }, + "placeholders": { + "export_message": "Exporting Performance Report (Placeholder)" + } + }, + "no_show_report": { + "title": "No-Show Report", + "subtitle": "Reliability tracking", + "metrics": { + "no_shows": "No-Shows", + "rate": "Rate", + "workers": "Workers" + }, + "workers_list_title": "WORKERS WITH NO-SHOWS", + "no_show_count": "$count no-show(s)", + "latest_incident": "Latest incident", + "risks": { + "high": "High Risk", + "medium": "Medium Risk", + "low": "Low Risk" + }, + "empty_state": "No workers flagged for no-shows", + "placeholders": { + "export_message": "Exporting No-Show Report (Placeholder)" + } + }, + "coverage_report": { + "title": "Coverage Report", + "subtitle": "Staffing levels & gaps", + "metrics": { + "avg_coverage": "Avg Coverage", + "full": "Full", + "needs_help": "Needs Help" + }, + "next_7_days": "NEXT 7 DAYS", + "empty_state": "No shifts scheduled", + "shift_item": { + "confirmed_workers": "$confirmed/$needed workers confirmed", + "spots_remaining": "$count spots remaining", + "one_spot_remaining": "1 spot remaining", + "fully_staffed": "Fully staffed" + }, + "placeholders": { + "export_message": "Exporting Coverage Report (Placeholder)" + } + } + }, + "client_coverage": { + "worker_row": { + "verify": "Verify", + "verified_message": "Worker attire verified for $name" + } + }, + "staff_payments": { + "early_pay": { + "title": "Early Pay", + "available_label": "Available for Cash Out", + "select_amount": "Select Amount", + "hint_amount": "Enter amount to cash out", + "deposit_to": "Instant deposit to:", + "confirm_button": "Confirm Cash Out", + "success_message": "Cash out request submitted!", + "fee_notice": "A small fee of \\$1.99 may apply for instant transfers." + } } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index ee54965e..076a4da6 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -13,139 +13,139 @@ "staff_authentication": { "get_started_page": { "title_part1": "Trabaja, Crece, ", - "title_part2": "Elรฉvate", - "subtitle": "Construye tu carrera en hostelerรญa con \nflexibilidad y libertad.", + "title_part2": "El\u00e9vate", + "subtitle": "Construye tu carrera en hosteler\u00eda con \nflexibilidad y libertad.", "sign_up_button": "Registrarse", - "log_in_button": "Iniciar sesiรณn" + "log_in_button": "Iniciar sesi\u00f3n" }, "phone_verification_page": { - "validation_error": "Por favor, ingresa un nรบmero de telรฉfono vรกlido de 10 dรญgitos", - "send_code_button": "Enviar cรณdigo", - "enter_code_title": "Ingresa el cรณdigo de verificaciรณn", - "code_sent_message": "Enviamos un cรณdigo de 6 dรญgitos a ", - "code_sent_instruction": ". Ingrรฉsalo a continuaciรณn para verificar tu cuenta." + "validation_error": "Por favor, ingresa un n\u00famero de tel\u00e9fono v\u00e1lido de 10 d\u00edgitos", + "send_code_button": "Enviar c\u00f3digo", + "enter_code_title": "Ingresa el c\u00f3digo de verificaci\u00f3n", + "code_sent_message": "Enviamos un c\u00f3digo de 6 d\u00edgitos a ", + "code_sent_instruction": ". Ingr\u00e9salo a continuaci\u00f3n para verificar tu cuenta." }, "phone_input": { - "title": "Verifica tu nรบmero de telรฉfono", - "subtitle": "Te enviaremos un cรณdigo de verificaciรณn para comenzar.", - "label": "Nรบmero de telรฉfono", - "hint": "Ingresa tu nรบmero" + "title": "Verifica tu n\u00famero de tel\u00e9fono", + "subtitle": "Te enviaremos un c\u00f3digo de verificaci\u00f3n para comenzar.", + "label": "N\u00famero de tel\u00e9fono", + "hint": "Ingresa tu n\u00famero" }, "otp_verification": { - "did_not_get_code": "ยฟNo recibiste el cรณdigo?", + "did_not_get_code": "\u00bfNo recibiste el c\u00f3digo?", "resend_in": "Reenviar en $seconds s", - "resend_code": "Reenviar cรณdigo" + "resend_code": "Reenviar c\u00f3digo" }, "profile_setup_page": { "step_indicator": "Paso $current de $total", - "error_occurred": "Ocurriรณ un error", - "complete_setup_button": "Completar configuraciรณn", + "error_occurred": "Ocurri\u00f3 un error", + "complete_setup_button": "Completar configuraci\u00f3n", "steps": { - "basic": "Informaciรณn bรกsica", - "location": "Ubicaciรณn", + "basic": "Informaci\u00f3n b\u00e1sica", + "location": "Ubicaci\u00f3n", "experience": "Experiencia" }, "basic_info": { - "title": "Conozcรกmonos", - "subtitle": "Cuรฉntanos un poco sobre ti", + "title": "Conozc\u00e1monos", + "subtitle": "Cu\u00e9ntanos un poco sobre ti", "full_name_label": "Nombre completo *", - "full_name_hint": "Juan Pรฉrez", - "bio_label": "Biografรญa corta", - "bio_hint": "Profesional experimentado en hostelerรญa..." + "full_name_hint": "Juan P\u00e9rez", + "bio_label": "Biograf\u00eda corta", + "bio_hint": "Profesional experimentado en hosteler\u00eda..." }, "location": { - "title": "ยฟDรณnde quieres trabajar?", + "title": "\u00bfD\u00f3nde quieres trabajar?", "subtitle": "Agrega tus ubicaciones de trabajo preferidas", "full_name_label": "Nombre completo", - "add_location_label": "Agregar ubicaciรณn *", - "add_location_hint": "Ciudad o cรณdigo postal", + "add_location_label": "Agregar ubicaci\u00f3n *", + "add_location_hint": "Ciudad o c\u00f3digo postal", "add_button": "Agregar", - "max_distance": "Distancia mรกxima: $distance millas", + "max_distance": "Distancia m\u00e1xima: $distance millas", "min_dist_label": "5 mi", "max_dist_label": "50 mi" }, "experience": { - "title": "ยฟCuรกles son tus habilidades?", + "title": "\u00bfCu\u00e1les son tus habilidades?", "subtitle": "Selecciona todas las que correspondan", "skills_label": "Habilidades *", "industries_label": "Industrias preferidas", "skills": { "food_service": "Servicio de comida", - "bartending": "Preparaciรณn de bebidas", - "warehouse": "Almacรฉn", + "bartending": "Preparaci\u00f3n de bebidas", + "warehouse": "Almac\u00e9n", "retail": "Venta minorista", "events": "Eventos", "customer_service": "Servicio al cliente", "cleaning": "Limpieza", "security": "Seguridad", - "driving": "Conducciรณn", + "driving": "Conducci\u00f3n", "cooking": "Cocina", "cashier": "Cajero", "server": "Mesero", "barista": "Barista", - "host_hostess": "Anfitriรณn", + "host_hostess": "Anfitri\u00f3n", "busser": "Ayudante de mesero" }, "industries": { - "hospitality": "Hostelerรญa", + "hospitality": "Hosteler\u00eda", "food_service": "Servicio de comida", - "warehouse": "Almacรฉn", + "warehouse": "Almac\u00e9n", "events": "Eventos", "retail": "Venta minorista", - "healthcare": "Atenciรณn mรฉdica" + "healthcare": "Atenci\u00f3n m\u00e9dica" } } }, "common": { - "trouble_question": "ยฟTienes problemas? ", + "trouble_question": "\u00bfTienes problemas? ", "contact_support": "Contactar a soporte" } }, "client_authentication": { "get_started_page": { "title": "Toma el control de tus\nturnos y eventos", - "subtitle": "Optimiza tus operaciones con potentes herramientas para gestionar horarios, realizar un seguimiento del rendimiento y mantener a tu equipo en la misma pรกgina, todo en un solo lugar", - "sign_in_button": "Iniciar sesiรณn", + "subtitle": "Optimiza tus operaciones con potentes herramientas para gestionar horarios, realizar un seguimiento del rendimiento y mantener a tu equipo en la misma p\u00e1gina, todo en un solo lugar", + "sign_in_button": "Iniciar sesi\u00f3n", "create_account_button": "Crear cuenta" }, "sign_in_page": { "title": "Bienvenido de nuevo", - "subtitle": "Inicia sesiรณn para gestionar tus turnos y trabajadores", - "email_label": "Correo electrรณnico", - "email_hint": "Ingresa tu correo electrรณnico", - "password_label": "Contraseรฑa", - "password_hint": "Ingresa tu contraseรฑa", - "forgot_password": "ยฟOlvidaste tu contraseรฑa?", - "sign_in_button": "Iniciar sesiรณn", + "subtitle": "Inicia sesi\u00f3n para gestionar tus turnos y trabajadores", + "email_label": "Correo electr\u00f3nico", + "email_hint": "Ingresa tu correo electr\u00f3nico", + "password_label": "Contrase\u00f1a", + "password_hint": "Ingresa tu contrase\u00f1a", + "forgot_password": "\u00bfOlvidaste tu contrase\u00f1a?", + "sign_in_button": "Iniciar sesi\u00f3n", "or_divider": "o", - "social_apple": "Iniciar sesiรณn con Apple", - "social_google": "Iniciar sesiรณn con Google", - "no_account": "ยฟNo tienes una cuenta? ", - "sign_up_link": "Regรญstrate" + "social_apple": "Iniciar sesi\u00f3n con Apple", + "social_google": "Iniciar sesi\u00f3n con Google", + "no_account": "\u00bfNo tienes una cuenta? ", + "sign_up_link": "Reg\u00edstrate" }, "sign_up_page": { "title": "Crear cuenta", "subtitle": "Comienza con Krow para tu negocio", "company_label": "Nombre de la empresa", "company_hint": "Ingresa el nombre de la empresa", - "email_label": "Correo electrรณnico", - "email_hint": "Ingresa tu correo electrรณnico", - "password_label": "Contraseรฑa", - "password_hint": "Crea una contraseรฑa", - "confirm_password_label": "Confirmar contraseรฑa", - "confirm_password_hint": "Confirma tu contraseรฑa", + "email_label": "Correo electr\u00f3nico", + "email_hint": "Ingresa tu correo electr\u00f3nico", + "password_label": "Contrase\u00f1a", + "password_hint": "Crea una contrase\u00f1a", + "confirm_password_label": "Confirmar contrase\u00f1a", + "confirm_password_hint": "Confirma tu contrase\u00f1a", "create_account_button": "Crear cuenta", "or_divider": "o", - "social_apple": "Regรญstrate con Apple", - "social_google": "Regรญstrate con Google", - "has_account": "ยฟYa tienes una cuenta? ", - "sign_in_link": "Iniciar sesiรณn" + "social_apple": "Reg\u00edstrate con Apple", + "social_google": "Reg\u00edstrate con Google", + "has_account": "\u00bfYa tienes una cuenta? ", + "sign_in_link": "Iniciar sesi\u00f3n" } }, "client_home": { "dashboard": { "welcome_back": "Bienvenido de nuevo", - "edit_mode_active": "Modo Ediciรณn Activo", + "edit_mode_active": "Modo Edici\u00f3n Activo", "drag_instruction": "Arrastra para reordenar, cambia la visibilidad", "reset": "Restablecer", "todays_coverage": "COBERTURA DE HOY", @@ -155,24 +155,24 @@ "metric_open": "Abierto", "spending": { "this_week": "Esta Semana", - "next_7_days": "Prรณximos 7 Dรญas", + "next_7_days": "Pr\u00f3ximos 7 D\u00edas", "shifts_count": "$count turnos", "scheduled_count": "$count programados" }, "view_all": "Ver todo", "insight_lightbulb": "Ahorra $amount/mes", - "insight_tip": "Reserva con 48h de antelaciรณn para mejores tarifas" + "insight_tip": "Reserva con 48h de antelaci\u00f3n para mejores tarifas" }, "widgets": { - "actions": "Acciones Rรกpidas", + "actions": "Acciones R\u00e1pidas", "reorder": "Reordenar", "coverage": "Cobertura de Hoy", - "spending": "Informaciรณn de Gastos", + "spending": "Informaci\u00f3n de Gastos", "live_activity": "Actividad en Vivo" }, "actions": { - "rapid": "RรPIDO", - "rapid_subtitle": "Urgente mismo dรญa", + "rapid": "R\u00c1PIDO", + "rapid_subtitle": "Urgente mismo d\u00eda", "create_order": "Crear Orden", "create_order_subtitle": "Programar turnos", "hubs": "Hubs", @@ -189,10 +189,10 @@ "review_subtitle": "Revisa y edita los detalles antes de publicar", "date_label": "Fecha *", "date_hint": "mm/dd/aaaa", - "location_label": "Ubicaciรณn *", - "location_hint": "Direcciรณn del negocio", + "location_label": "Ubicaci\u00f3n *", + "location_hint": "Direcci\u00f3n del negocio", "positions_title": "Posiciones", - "add_position": "Aรฑadir Posiciรณn", + "add_position": "A\u00f1adir Posici\u00f3n", "role_label": "Rol *", "role_hint": "Seleccionar rol", "start_time": "Hora de Inicio *", @@ -207,73 +207,121 @@ "title": "Perfil", "edit_profile": "Editar Perfil", "hubs": "Hubs", - "log_out": "Cerrar sesiรณn", - "quick_links": "Enlaces rรกpidos", + "log_out": "Cerrar sesi\u00f3n", + "log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?", + "quick_links": "Enlaces r\u00e1pidos", "clock_in_hubs": "Hubs de Marcaje", - "billing_payments": "Facturaciรณn y Pagos" + "billing_payments": "Facturaci\u00f3n y Pagos" + }, + "preferences": { + "title": "PREFERENCIAS", + "push": "Notificaciones Push", + "email": "Notificaciones por Correo", + "sms": "Notificaciones SMS" + }, + "edit_profile": { + "title": "Editar Perfil", + "first_name": "NOMBRE", + "last_name": "APELLIDO", + "email": "CORREO ELECTR\u00d3NICO", + "phone": "N\u00daMERO DE TEL\u00c9FONO", + "save_button": "Guardar Cambios", + "success_message": "Perfil actualizado exitosamente" } }, "client_hubs": { "title": "Hubs", "subtitle": "Gestionar ubicaciones de marcaje", - "add_hub": "Aรฑadir Hub", + "add_hub": "A\u00f1adir Hub", "empty_state": { - "title": "No hay hubs aรบn", + "title": "No hay hubs a\u00fan", "description": "Crea estaciones de marcaje para tus ubicaciones", - "button": "Aรฑade tu primer Hub" + "button": "A\u00f1ade tu primer Hub" }, "about_hubs": { "title": "Sobre los Hubs", - "description": "Los Hubs son estaciones de marcaje en tus ubicaciones. Asigna etiquetas NFC a cada hub para que los trabajadores puedan marcar entrada/salida rรกpidamente usando sus telรฉfonos." + "description": "Los Hubs son estaciones de marcaje en tus ubicaciones. Asigna etiquetas NFC a cada hub para que los trabajadores puedan marcar entrada/salida r\u00e1pidamente usando sus tel\u00e9fonos." }, "hub_card": { "tag_label": "Etiqueta: $id" }, "add_hub_dialog": { - "title": "Aรฑadir Nuevo Hub", + "title": "A\u00f1adir Nuevo Hub", "name_label": "Nombre del Hub *", - "name_hint": "ej., Cocina Principal, Recepciรณn", - "location_label": "Nombre de la Ubicaciรณn", + "name_hint": "ej., Cocina Principal, Recepci\u00f3n", + "location_label": "Nombre de la Ubicaci\u00f3n", "location_hint": "ej., Restaurante Centro", - "address_label": "Direcciรณn", - "address_hint": "Direcciรณn completa", + "address_label": "Direcci\u00f3n", + "address_hint": "Direcci\u00f3n completa", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "Nombre es obligatorio", + "address_required": "La direcci\u00f3n es obligatoria", "create_button": "Crear Hub" }, "nfc_dialog": { "title": "Identificar Etiqueta NFC", - "instruction": "Acerque su telรฉfono a la etiqueta NFC para identificarla", + "instruction": "Acerque su tel\u00e9fono a la etiqueta NFC para identificarla", "scan_button": "Escanear Etiqueta NFC", "tag_identified": "Etiqueta Identificada", "assign_button": "Asignar Etiqueta" }, "delete_dialog": { - "title": "Confirmar eliminaciรณn de Hub", - "message": "ยฟEstรกs seguro de que quieres eliminar \"$hubName\"?", - "undo_warning": "Esta acciรณn no se puede deshacer.", - "dependency_warning": "Ten en cuenta que si hay turnos/รณrdenes asignados a este hub no deberรญamos poder eliminarlo.", + "title": "Confirmar eliminaci\u00f3n de Hub", + "message": "\u00bfEst\u00e1s seguro de que quieres eliminar \"$hubName\"?", + "undo_warning": "Esta acci\u00f3n no se puede deshacer.", + "dependency_warning": "Ten en cuenta que si hay turnos/\u00f3rdenes asignados a este hub no deber\u00edamos poder eliminarlo.", "cancel": "Cancelar", "delete": "Eliminar" + }, + "edit_hub": { + "title": "Editar Hub", + "subtitle": "Actualizar detalles del hub", + "name_label": "Nombre del Hub", + "name_hint": "Ingresar nombre del hub", + "address_label": "Direcci\u00f3n", + "address_hint": "Ingresar direcci\u00f3n", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "El nombre es obligatorio", + "save_button": "Guardar Cambios", + "success": "\u00a1Hub actualizado exitosamente!", + "created_success": "Hub creado exitosamente", + "updated_success": "Hub actualizado exitosamente" + }, + "hub_details": { + "title": "Detalles del Hub", + "edit_button": "Editar", + "name_label": "Nombre del Hub", + "address_label": "Direcci\u00f3n", + "nfc_label": "Etiqueta NFC", + "nfc_not_assigned": "No asignada", + "cost_center_label": "Centro de Costos", + "cost_center_none": "No asignado", + "deleted_success": "Hub eliminado exitosamente" } }, "client_create_order": { "title": "Crear Orden", "section_title": "TIPO DE ORDEN", "types": { - "rapid": "RรPIDO", - "rapid_desc": "Cobertura URGENTE mismo dรญa", - "one_time": "รšnica Vez", - "one_time_desc": "Evento รšnico o Peticiรณn de Turno", + "rapid": "R\u00c1PIDO", + "rapid_desc": "Cobertura URGENTE mismo d\u00eda", + "one_time": "\u00danica Vez", + "one_time_desc": "Evento \u00danico o Petici\u00f3n de Turno", "recurring": "Recurrente", "recurring_desc": "Cobertura Continua Semanal / Mensual", "permanent": "Permanente", - "permanent_desc": "Colocaciรณn de Personal a Largo Plazo" + "permanent_desc": "Colocaci\u00f3n de Personal a Largo Plazo" }, "rapid": { - "title": "Orden RรPIDA", + "title": "Orden R\u00c1PIDA", "subtitle": "Personal de emergencia en minutos", "urgent_badge": "URGENTE", - "tell_us": "Dinos quรฉ necesitas", - "need_staff": "ยฟNecesitas personal urgentemente?", + "tell_us": "Dinos qu\u00e9 necesitas", + "need_staff": "\u00bfNecesitas personal urgentemente?", "type_or_speak": "Escribe o habla lo que necesitas. Yo me encargo del resto", "example": "Ejemplo: ", "hint": "Escribe o habla... (ej., \"Necesito 5 cocineros YA hasta las 5am\")", @@ -281,35 +329,40 @@ "listening": "Escuchando...", "send": "Enviar Mensaje", "sending": "Enviando...", - "success_title": "ยกSolicitud Enviada!", + "success_title": "\u00a1Solicitud Enviada!", "success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.", - "back_to_orders": "Volver a ร“rdenes" + "back_to_orders": "Volver a \u00d3rdenes" }, "one_time": { - "title": "Orden รšnica Vez", - "subtitle": "Evento รบnico o peticiรณn de turno", + "title": "Orden \u00danica Vez", + "subtitle": "Evento \u00fanico o petici\u00f3n de turno", "create_your_order": "Crea Tu Orden", "date_label": "Fecha", "date_hint": "Seleccionar fecha", - "location_label": "Ubicaciรณn", - "location_hint": "Ingresar direcciรณn", + "location_label": "Ubicaci\u00f3n", + "location_hint": "Ingresar direcci\u00f3n", + "hub_manager_label": "Contacto del Turno", + "hub_manager_desc": "Gerente o supervisor en el sitio para este turno", + "hub_manager_hint": "Seleccionar Contacto", + "hub_manager_empty": "No hay contactos de turno disponibles", + "hub_manager_none": "Ninguno", "positions_title": "Posiciones", - "add_position": "Aรฑadir Posiciรณn", - "position_number": "Posiciรณn $number", + "add_position": "A\u00f1adir Posici\u00f3n", + "position_number": "Posici\u00f3n $number", "remove": "Eliminar", "select_role": "Seleccionar rol", "start_label": "Inicio", "end_label": "Fin", "workers_label": "Trabajadores", "lunch_break_label": "Descanso para Almuerzo", - "different_location": "Usar ubicaciรณn diferente para esta posiciรณn", - "different_location_title": "Ubicaciรณn Diferente", - "different_location_hint": "Ingresar direcciรณn diferente", + "different_location": "Usar ubicaci\u00f3n diferente para esta posici\u00f3n", + "different_location_title": "Ubicaci\u00f3n Diferente", + "different_location_hint": "Ingresar direcci\u00f3n diferente", "create_order": "Crear Orden", "creating": "Creando...", - "success_title": "ยกOrden Creada!", - "success_message": "Tu solicitud de turno ha sido publicada. Los trabajadores comenzarรกn a postularse pronto.", - "back_to_orders": "Volver a ร“rdenes", + "success_title": "\u00a1Orden Creada!", + "success_message": "Tu solicitud de turno ha sido publicada. Los trabajadores comenzar\u00e1n a postularse pronto.", + "back_to_orders": "Volver a \u00d3rdenes", "no_break": "Sin descanso", "paid_break": "min (Pagado)", "unpaid_break": "min (No pagado)" @@ -321,29 +374,64 @@ }, "permanent": { "title": "Orden Permanente", - "subtitle": "Colocaciรณn de personal a largo plazo", + "subtitle": "Colocaci\u00f3n de personal a largo plazo", "placeholder": "Flujo de Orden Permanente (Trabajo en Progreso)" } }, "client_main": { "tabs": { "coverage": "Cobertura", - "billing": "Facturaciรณn", + "billing": "Facturaci\u00f3n", "home": "Inicio", - "orders": "ร“rdenes", + "orders": "\u00d3rdenes", "reports": "Reportes" } }, "client_view_orders": { - "title": "ร“rdenes", + "title": "\u00d3rdenes", "post_button": "Publicar", "post_order": "Publicar una Orden", - "no_orders": "No hay รณrdenes para $date", + "no_orders": "No hay \u00f3rdenes para $date", "tabs": { - "up_next": "Prรณximos", + "up_next": "Pr\u00f3ximos", "active": "Activos", "completed": "Completados" }, + "order_edit_sheet": { + "title": "Editar Tu Orden", + "vendor_section": "PROVEEDOR", + "location_section": "UBICACI\u00d3N", + "shift_contact_section": "CONTACTO DEL TURNO", + "shift_contact_desc": "Gerente o supervisor en el sitio para este turno", + "select_contact": "Seleccionar Contacto", + "no_hub_managers": "No hay contactos de turno disponibles", + "none": "Ninguno", + "positions_section": "POSICIONES", + "add_position": "A\u00f1adir Posici\u00f3n", + "review_positions": "Revisar $count Posiciones", + "order_name_hint": "Nombre de la orden", + "remove": "Eliminar", + "select_role_hint": "Seleccionar rol", + "start_label": "Inicio", + "end_label": "Fin", + "workers_label": "Trabajadores", + "different_location": "Usar ubicaci\u00f3n diferente para esta posici\u00f3n", + "different_location_title": "Ubicaci\u00f3n Diferente", + "enter_address_hint": "Ingresar direcci\u00f3n diferente", + "no_break": "Sin Descanso", + "positions": "Posiciones", + "workers": "Trabajadores", + "est_cost": "Costo Est.", + "positions_breakdown": "Desglose de Posiciones", + "edit_button": "Editar", + "confirm_save": "Confirmar y Guardar", + "position_singular": "Posici\u00f3n", + "order_updated_title": "\u00a1Orden Actualizada!", + "order_updated_message": "Tu turno ha sido actualizado exitosamente.", + "back_to_orders": "Volver a \u00d3rdenes", + "one_time_order_title": "Orden \u00danica Vez", + "refine_subtitle": "Ajusta tus necesidades de personal" + }, "card": { "open": "ABIERTO", "filled": "LLENO", @@ -351,7 +439,7 @@ "in_progress": "EN PROGRESO", "completed": "COMPLETADO", "cancelled": "CANCELADO", - "get_direction": "Obtener direcciรณn", + "get_direction": "Obtener direcci\u00f3n", "total": "Total", "hrs": "Hrs", "workers": "$count trabajadores", @@ -360,43 +448,88 @@ "coverage": "Cobertura", "workers_label": "$filled/$needed Trabajadores", "confirmed_workers": "Trabajadores Confirmados", - "no_workers": "Ningรบn trabajador confirmado aรบn.", + "no_workers": "Ning\u00fan trabajador confirmado a\u00fan.", "today": "Hoy", - "tomorrow": "Maรฑana", + "tomorrow": "Ma\u00f1ana", "workers_needed": "$count Trabajadores Necesarios", "all_confirmed": "Todos los trabajadores confirmados", "confirmed_workers_title": "TRABAJADORES CONFIRMADOS", "message_all": "Mensaje a todos", - "show_more_workers": "Mostrar $count trabajadores mรกs", + "show_more_workers": "Mostrar $count trabajadores m\u00e1s", "checked_in": "Registrado", "call_dialog": { "title": "Llamar", - "message": "ยฟQuieres llamar a $phone?" + "message": "\u00bfQuieres llamar a $phone?" } } }, "client_billing": { - "title": "Facturaciรณn", - "current_period": "Perรญodo Actual", + "title": "Facturaci\u00f3n", + "current_period": "Per\u00edodo Actual", "saved_amount": "$amount ahorrado", - "awaiting_approval": "Esperando Aprobaciรณn", - "payment_method": "Mรฉtodo de Pago", - "add_payment": "Aรฑadir", + "awaiting_approval": "Esperando Aprobaci\u00f3n", + "payment_method": "M\u00e9todo de Pago", + "add_payment": "A\u00f1adir", "default_badge": "Predeterminado", "expires": "Expira $date", - "period_breakdown": "Desglose de este Perรญodo", + "period_breakdown": "Desglose de este Per\u00edodo", "week": "Semana", "month": "Mes", "total": "Total", "hours": "$count horas", - "rate_optimization_title": "Optimizaciรณn de Tarifas", - "rate_optimization_body": "Ahorra $amount/mes cambiando 3 turnos", + "export_button": "Exportar Todas las Facturas", + "rate_optimization_title": "Optimizaci\u00f3n de Tarifas", + "rate_optimization_save": "Ahorra ", + "rate_optimization_amount": "$amount/mes", + "rate_optimization_shifts": " cambiando 3 turnos", "view_details": "Ver Detalles", + "no_invoices_period": "No hay facturas para el per\u00edodo seleccionado", + "invoices_ready_title": "Facturas Listas", + "invoices_ready_subtitle": "Tienes elementos aprobados listos para el pago.", + "retry": "Reintentar", + "error_occurred": "Ocurri\u00f3 un error", "invoice_history": "Historial de Facturas", "view_all": "Ver todo", - "export_button": "Exportar Todas las Facturas", - "pending_badge": "PENDIENTE APROBACIร“N", - "paid_badge": "PAGADO" + "approved_success": "Factura aprobada y pago iniciado", + "flagged_success": "Factura marcada para revisi\u00f3n", + "pending_badge": "PENDIENTE", + "paid_badge": "PAGADO", + "all_caught_up": "\u00a1Todo al d\u00eda!", + "no_pending_invoices": "No hay facturas esperando aprobaci\u00f3n", + "review_and_approve": "Revisar y Aprobar", + "review_and_approve_subtitle": "Revisar y aprobar para el pago", + "invoice_ready": "Factura Lista", + "total_amount_label": "Monto Total", + "hours_suffix": "horas", + "avg_rate_suffix": "/hr prom", + "stats": { + "total": "Total", + "workers": "trabajadores", + "hrs": "HRS" + }, + "workers_tab": { + "title": "Trabajadores ($count)", + "search_hint": "Buscar trabajadores...", + "needs_review": "Necesita Revisi\u00f3n ($count)", + "all": "Todos ($count)", + "min_break": "min de descanso" + }, + "actions": { + "approve_pay": "Aprobar y Procesar Pago", + "flag_review": "Marcar para Revisi\u00f3n", + "download_pdf": "Descargar PDF de Factura" + }, + "flag_dialog": { + "title": "Marcar para Revisi\u00f3n", + "hint": "Describe el problema...", + "button": "Marcar" + }, + "timesheets": { + "title": "Hojas de Tiempo", + "approve_button": "Aprobar", + "decline_button": "Rechazar", + "approved_message": "Hoja de tiempo aprobada" + } }, "staff": { "main": { @@ -415,9 +548,9 @@ }, "banners": { "complete_profile_title": "Completa tu Perfil", - "complete_profile_subtitle": "Verifรญcate para ver mรกs turnos", + "complete_profile_subtitle": "Verif\u00edcate para ver m\u00e1s turnos", "availability_title": "Disponibilidad", - "availability_subtitle": "Actualiza tu disponibilidad para la prรณxima semana" + "availability_subtitle": "Actualiza tu disponibilidad para la pr\u00f3xima semana" }, "quick_actions": { "find_shifts": "Buscar Turnos", @@ -428,14 +561,14 @@ "sections": { "todays_shift": "Turno de Hoy", "scheduled_count": "$count programados", - "tomorrow": "Maรฑana", + "tomorrow": "Ma\u00f1ana", "recommended_for_you": "Recomendado para Ti", "view_all": "Ver todo" }, "empty_states": { "no_shifts_today": "No hay turnos programados para hoy", - "find_shifts_cta": "Buscar turnos โ†’", - "no_shifts_tomorrow": "No hay turnos para maรฑana", + "find_shifts_cta": "Buscar turnos \u2192", + "no_shifts_tomorrow": "No hay turnos para ma\u00f1ana", "no_recommended_shifts": "No hay turnos recomendados" }, "pending_payment": { @@ -444,8 +577,8 @@ "amount": "$amount" }, "recommended_card": { - "act_now": "โ€ข ACTรšA AHORA", - "one_day": "Un Dรญa", + "act_now": "\u2022 ACT\u00daA AHORA", + "one_day": "Un D\u00eda", "today": "Hoy", "applied_for": "Postulado para $title", "time_range": "$start - $end" @@ -455,28 +588,43 @@ "view_all": "Ver todo", "hours_label": "horas", "items": { - "sick_days": "Dรญas de Enfermedad", + "sick_days": "D\u00edas de Enfermedad", "vacation": "Vacaciones", "holidays": "Festivos" + }, + "overview": { + "title": "Resumen de tus Beneficios", + "subtitle": "Gestiona y sigue tus beneficios ganados aqu\u00ed", + "request_payment": "Solicitar pago por $benefit", + "request_submitted": "Solicitud enviada para $benefit", + "sick_leave_subtitle": "Necesitas al menos 8 horas para solicitar d\u00edas de enfermedad", + "vacation_subtitle": "Necesitas 40 horas para reclamar el pago de vacaciones", + "holidays_subtitle": "D\u00edas festivos pagados: Acci\u00f3n de Gracias, Navidad, A\u00f1o Nuevo", + "sick_leave_history": "HISTORIAL DE D\u00cdAS DE ENFERMEDAD", + "compliance_banner": "Los certificados listados son obligatorios para los empleados. Si el empleado no tiene los certificados completos, no puede proceder con su registro.", + "status": { + "pending": "Pendiente", + "submitted": "Enviado" + } } }, "auto_match": { "title": "Auto-Match", "finding_shifts": "Buscando turnos para ti", - "get_matched": "Sรฉ emparejado automรกticamente", + "get_matched": "S\u00e9 emparejado autom\u00e1ticamente", "matching_based_on": "Emparejamiento basado en:", "chips": { - "location": "Ubicaciรณn", + "location": "Ubicaci\u00f3n", "availability": "Disponibilidad", "skills": "Habilidades" } }, "improve": { - "title": "Mejรณrate a ti mismo", + "title": "Mej\u00f3rate a ti mismo", "items": { "training": { - "title": "Secciรณn de Entrenamiento", - "description": "Mejora tus habilidades y obtรฉn certificaciones.", + "title": "Secci\u00f3n de Entrenamiento", + "description": "Mejora tus habilidades y obt\u00e9n certificaciones.", "page": "/krow-university" }, "podcast": { @@ -487,7 +635,7 @@ } }, "more_ways": { - "title": "Mรกs Formas de Usar Krow", + "title": "M\u00e1s Formas de Usar Krow", "items": { "benefits": { "title": "Beneficios de Krow", @@ -503,31 +651,32 @@ "profile": { "header": { "title": "Perfil", - "sign_out": "CERRAR SESIร“N" + "sign_out": "CERRAR SESI\u00d3N" }, "reliability_stats": { "shifts": "Turnos", - "rating": "Calificaciรณn", + "rating": "Calificaci\u00f3n", "on_time": "A Tiempo", "no_shows": "Faltas", "cancellations": "Cancel." }, "reliability_score": { - "title": "Puntuaciรณn de Confiabilidad", - "description": "Mantรฉn tu puntuaciรณn por encima del 45% para continuar aceptando turnos." + "title": "Puntuaci\u00f3n de Confiabilidad", + "description": "Mant\u00e9n tu puntuaci\u00f3n por encima del 45% para continuar aceptando turnos." }, "sections": { - "onboarding": "INCORPORACIร“N", + "onboarding": "INCORPORACI\u00d3N", "compliance": "CUMPLIMIENTO", "level_up": "MEJORAR NIVEL", "finance": "FINANZAS", - "support": "SOPORTE" + "support": "SOPORTE", + "settings": "AJUSTES" }, "menu_items": { - "personal_info": "Informaciรณn Personal", + "personal_info": "Informaci\u00f3n Personal", "emergency_contact": "Contacto de Emergencia", "emergency_contact_page": { - "save_success": "Contactos de emergencia guardados con รฉxito", + "save_success": "Contactos de emergencia guardados con \u00e9xito", "save_continue": "Guardar y Continuar" }, "experience": "Experiencia", @@ -537,27 +686,28 @@ "tax_forms": "Formularios Fiscales", "krow_university": "Krow University", "trainings": "Capacitaciones", - "leaderboard": "Tabla de Clasificaciรณn", + "leaderboard": "Tabla de Clasificaci\u00f3n", "bank_account": "Cuenta Bancaria", "payments": "Pagos", "timecard": "Tarjeta de Tiempo", "faqs": "Preguntas Frecuentes", "privacy_security": "Privacidad y Seguridad", - "messages": "Mensajes" + "messages": "Mensajes", + "language": "Idioma" }, "bank_account_page": { "title": "Cuenta Bancaria", "linked_accounts": "Cuentas Vinculadas", "add_account": "Agregar Cuenta Bancaria", "secure_title": "Seguro y Cifrado", - "secure_subtitle": "Su informaciรณn bancaria estรก cifrada y almacenada de forma segura. Nunca compartimos sus detalles.", + "secure_subtitle": "Su informaci\u00f3n bancaria est\u00e1 cifrada y almacenada de forma segura. Nunca compartimos sus detalles.", "add_new_account": "Agregar Nueva Cuenta", "bank_name": "Nombre del Banco", "bank_hint": "Ingrese nombre del banco", - "routing_number": "Nรบmero de Ruta", - "routing_hint": "9 dรญgitos", - "account_number": "Nรบmero de Cuenta", - "account_hint": "Ingrese nรบmero de cuenta", + "routing_number": "N\u00famero de Ruta", + "routing_hint": "9 d\u00edgitos", + "account_number": "N\u00famero de Cuenta", + "account_hint": "Ingrese n\u00famero de cuenta", "account_type": "Tipo de Cuenta", "checking": "CORRIENTE", "savings": "AHORROS", @@ -565,42 +715,55 @@ "save": "Guardar", "primary": "Principal", "account_ending": "Termina en $last4", - "account_added_success": "ยกCuenta bancaria agregada exitosamente!" + "account_added_success": "\u00a1Cuenta bancaria agregada exitosamente!" }, "logout": { - "button": "Cerrar Sesiรณn" + "button": "Cerrar Sesi\u00f3n" } }, "onboarding": { "personal_info": { - "title": "Informaciรณn Personal", + "title": "Informaci\u00f3n Personal", "change_photo_hint": "Toca para cambiar foto", "full_name_label": "Nombre Completo", - "email_label": "Correo Electrรณnico", - "phone_label": "Nรบmero de Telรฉfono", + "email_label": "Correo Electr\u00f3nico", + "phone_label": "N\u00famero de Tel\u00e9fono", "phone_hint": "+1 (555) 000-0000", - "bio_label": "Biografรญa", - "bio_hint": "Cuรฉntales a los clientes sobre ti...", + "bio_label": "Biograf\u00eda", + "bio_hint": "Cu\u00e9ntales a los clientes sobre ti...", "languages_label": "Idiomas", - "languages_hint": "Inglรฉs, Espaรฑol, Francรฉs...", + "languages_hint": "Ingl\u00e9s, Espa\u00f1ol, Franc\u00e9s...", "locations_label": "Ubicaciones Preferidas", "locations_hint": "Centro, Midtown, Brooklyn...", + "locations_summary_none": "No configurado", "save_button": "Guardar Cambios", - "save_success": "Informaciรณn personal guardada exitosamente" + "save_success": "Informaci\u00f3n personal guardada exitosamente", + "preferred_locations": { + "title": "Ubicaciones Preferidas", + "description": "Elige hasta 5 ubicaciones en los EE.UU. donde prefieres trabajar. Priorizaremos turnos cerca de estas \u00e1reas.", + "search_hint": "Buscar una ciudad o \u00e1rea...", + "added_label": "TUS UBICACIONES", + "max_reached": "Has alcanzado el m\u00e1ximo de 5 ubicaciones", + "min_hint": "Agrega al menos 1 ubicaci\u00f3n preferida", + "save_button": "Guardar Ubicaciones", + "save_success": "Ubicaciones preferidas guardadas", + "remove_tooltip": "Eliminar ubicaci\u00f3n", + "empty_state": "A\u00fan no has agregado ubicaciones.\nBusca arriba para agregar tus \u00e1reas de trabajo preferidas." + } }, "experience": { "title": "Experiencia y habilidades", "industries_title": "Industrias", "industries_subtitle": "Seleccione las industrias en las que tiene experiencia", "skills_title": "Habilidades", - "skills_subtitle": "Seleccione sus habilidades o aรฑada personalizadas", + "skills_subtitle": "Seleccione sus habilidades o a\u00f1ada personalizadas", "custom_skills_title": "Habilidades personalizadas:", - "custom_skill_hint": "Aรฑadir habilidad...", + "custom_skill_hint": "A\u00f1adir habilidad...", "save_button": "Guardar y continuar", "industries": { - "hospitality": "Hotelerรญa", + "hospitality": "Hoteler\u00eda", "food_service": "Servicio de alimentos", - "warehouse": "Almacรฉn", + "warehouse": "Almac\u00e9n", "events": "Eventos", "retail": "Venta al por menor", "healthcare": "Cuidado de la salud", @@ -610,8 +773,8 @@ "food_service": "Servicio de alimentos", "bartending": "Bartending", "event_setup": "Montaje de eventos", - "hospitality": "Hotelerรญa", - "warehouse": "Almacรฉn", + "hospitality": "Hoteler\u00eda", + "warehouse": "Almac\u00e9n", "customer_service": "Servicio al cliente", "cleaning": "Limpieza", "security": "Seguridad", @@ -620,7 +783,7 @@ "cashier": "Cajero", "server": "Mesero", "barista": "Barista", - "host_hostess": "Anfitriรณn/Anfitriona", + "host_hostess": "Anfitri\u00f3n/Anfitriona", "busser": "Ayudante de mesero", "driving": "Conducir" } @@ -631,9 +794,9 @@ "your_activity": "Su actividad", "selected_shift_badge": "TURNO SELECCIONADO", "today_shift_badge": "TURNO DE HOY", - "early_title": "ยกHa llegado temprano!", + "early_title": "\u00a1Ha llegado temprano!", "check_in_at": "Entrada disponible a las $time", - "shift_completed": "ยกTurno completado!", + "shift_completed": "\u00a1Turno completado!", "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", "accept_shift_cta": "Acepte un turno para registrar su entrada", @@ -644,13 +807,19 @@ "scanned_title": "NFC escaneado", "ready_to_scan": "Listo para escanear", "processing": "Verificando etiqueta...", - "scan_instruction": "Mantenga su telรฉfono cerca de la etiqueta NFC en el lugar para registrarse.", - "please_wait": "Espere un momento, estamos verificando su ubicaciรณn.", + "scan_instruction": "Mantenga su tel\u00e9fono cerca de la etiqueta NFC en el lugar para registrarse.", + "please_wait": "Espere un momento, estamos verificando su ubicaci\u00f3n.", "tap_to_scan": "Tocar para escanear (Simulado)" }, + "attire_photo_label": "Foto de Vestimenta", + "take_attire_photo": "Tomar Foto", + "attire_photo_desc": "Tome una foto de su vestimenta para verificaci\u00f3n.", + "attire_captured": "\u00a1Foto de vestimenta capturada!", + "location_verifying": "Verificando ubicaci\u00f3n...", + "not_in_range": "Debes estar dentro de $distance m para registrar entrada.", "commute": { - "enable_title": "ยฟActivar seguimiento de viaje?", - "enable_desc": "Comparta su ubicaciรณn 1 hora antes del turno para que su gerente sepa que estรก en camino.", + "enable_title": "\u00bfActivar seguimiento de viaje?", + "enable_desc": "Comparta su ubicaci\u00f3n 1 hora antes del turno para que su gerente sepa que est\u00e1 en camino.", "not_now": "Ahora no", "enable": "Activar", "on_my_way": "En camino", @@ -660,10 +829,10 @@ "distance_to_site": "Distancia al sitio", "estimated_arrival": "Llegada estimada", "eta_label": "$min min", - "locked_desc": "La mayorรญa de las funciones de la aplicaciรณn estรกn bloqueadas mientras el modo de viaje estรก activo. Podrรก registrar su entrada una vez que llegue.", + "locked_desc": "La mayor\u00eda de las funciones de la aplicaci\u00f3n est\u00e1n bloqueadas mientras el modo de viaje est\u00e1 activo. Podr\u00e1 registrar su entrada una vez que llegue.", "turn_off": "Desactivar modo de viaje", - "arrived_title": "ยกHas llegado! ๐ŸŽ‰", - "arrived_desc": "Estรกs en el lugar del turno. ยฟListo para registrar tu entrada?" + "arrived_title": "\u00a1Has llegado! \ud83c\udf89", + "arrived_desc": "Est\u00e1s en el lugar del turno. \u00bfListo para registrar tu entrada?" }, "swipe": { "checking_out": "Registrando salida...", @@ -672,58 +841,58 @@ "nfc_checkin": "NFC Entrada", "swipe_checkout": "Deslizar para registrar salida", "swipe_checkin": "Deslizar para registrar entrada", - "checkout_complete": "ยกSalida registrada!", - "checkin_complete": "ยกEntrada registrada!" + "checkout_complete": "\u00a1Salida registrada!", + "checkin_complete": "\u00a1Entrada registrada!" }, "lunch_break": { - "title": "ยฟTomaste un\nalmuerzo?", + "title": "\u00bfTomaste un\nalmuerzo?", "no": "No", - "yes": "Sรญ", - "when_title": "ยฟCuรกndo almorzaste?", + "yes": "S\u00ed", + "when_title": "\u00bfCu\u00e1ndo almorzaste?", "start": "Inicio", "end": "Fin", - "why_no_lunch": "ยฟPor quรฉ no almorzaste?", + "why_no_lunch": "\u00bfPor qu\u00e9 no almorzaste?", "reasons": [ "Flujos de trabajo impredecibles", - "Mala gestiรณn del tiempo", + "Mala gesti\u00f3n del tiempo", "Falta de cobertura o poco personal", - "No hay รกrea de almuerzo", + "No hay \u00e1rea de almuerzo", "Otro (especifique)" ], "additional_notes": "Notas adicionales", - "notes_placeholder": "Aรฑade cualquier detalle...", + "notes_placeholder": "A\u00f1ade cualquier detalle...", "next": "Siguiente", "submit": "Enviar", - "success_title": "ยกDescanso registrado!", + "success_title": "\u00a1Descanso registrado!", "close": "Cerrar" } }, "availability": { "title": "Mi disponibilidad", - "quick_set_title": "Establecer disponibilidad rรกpida", + "quick_set_title": "Establecer disponibilidad r\u00e1pida", "all_week": "Toda la semana", - "weekdays": "Dรญas laborables", + "weekdays": "D\u00edas laborables", "weekends": "Fines de semana", "clear_all": "Borrar todo", - "available_status": "Estรก disponible", + "available_status": "Est\u00e1 disponible", "not_available_status": "No disponible", "auto_match_title": "Auto-Match usa su disponibilidad", - "auto_match_description": "Cuando estรฉ activado, solo se le asignarรกn turnos durante sus horarios disponibles." + "auto_match_description": "Cuando est\u00e9 activado, solo se le asignar\u00e1n turnos durante sus horarios disponibles." } }, "staff_compliance": { "tax_forms": { "w4": { "title": "Formulario W-4", - "subtitle": "Certificado de Retenciรณn del Empleado", - "submitted_title": "ยกFormulario W-4 enviado!", - "submitted_desc": "Su certificado de retenciรณn ha sido enviado a su empleador.", + "subtitle": "Certificado de Retenci\u00f3n del Empleado", + "submitted_title": "\u00a1Formulario W-4 enviado!", + "submitted_desc": "Su certificado de retenci\u00f3n ha sido enviado a su empleador.", "back_to_docs": "Volver a Documentos", "step_label": "Paso $current de $total", "steps": { - "personal": "Informaciรณn Personal", - "filing": "Estado Civil para Efectos de la Declaraciรณn", - "multiple_jobs": "Mรบltiples Trabajos", + "personal": "Informaci\u00f3n Personal", + "filing": "Estado Civil para Efectos de la Declaraci\u00f3n", + "multiple_jobs": "M\u00faltiples Trabajos", "dependents": "Dependientes", "adjustments": "Otros Ajustes", "review": "Revisar y Firmar" @@ -731,56 +900,56 @@ "fields": { "first_name": "Nombre *", "last_name": "Apellido *", - "ssn": "Nรบmero de Seguro Social *", - "address": "Direcciรณn *", - "city_state_zip": "Ciudad, Estado, Cรณdigo Postal", + "ssn": "N\u00famero de Seguro Social *", + "address": "Direcci\u00f3n *", + "city_state_zip": "Ciudad, Estado, C\u00f3digo Postal", "placeholder_john": "Juan", - "placeholder_smith": "Pรฉrez", + "placeholder_smith": "P\u00e9rez", "placeholder_ssn": "XXX-XX-XXXX", "placeholder_address": "Calle Principal 123", - "placeholder_csz": "Ciudad de Mรฉxico, CDMX 01000", - "filing_info": "Su estado civil determina su deducciรณn estรกndar y tasas de impuestos.", - "single": "Soltero o Casado que presenta la declaraciรณn por separado", - "married": "Casado que presenta una declaraciรณn conjunta o Cรณnyuge sobreviviente calificado", + "placeholder_csz": "Ciudad de M\u00e9xico, CDMX 01000", + "filing_info": "Su estado civil determina su deducci\u00f3n est\u00e1ndar y tasas de impuestos.", + "single": "Soltero o Casado que presenta la declaraci\u00f3n por separado", + "married": "Casado que presenta una declaraci\u00f3n conjunta o C\u00f3nyuge sobreviviente calificado", "head": "Jefe de familia", - "head_desc": "Marque solo si es soltero y paga mรกs de la mitad de los costos de mantenimiento de un hogar", - "multiple_jobs_title": "ยฟCuรกndo completar este paso?", - "multiple_jobs_desc": "Complete este paso solo si tiene mรกs de un trabajo a la vez, o si estรก casado y presenta una declaraciรณn conjunta y su cรณnyuge tambiรฉn trabaja.", - "multiple_jobs_check": "Tengo mรบltiples trabajos o mi cรณnyuge trabaja", + "head_desc": "Marque solo si es soltero y paga m\u00e1s de la mitad de los costos de mantenimiento de un hogar", + "multiple_jobs_title": "\u00bfCu\u00e1ndo completar este paso?", + "multiple_jobs_desc": "Complete este paso solo si tiene m\u00e1s de un trabajo a la vez, o si est\u00e1 casado y presenta una declaraci\u00f3n conjunta y su c\u00f3nyuge tambi\u00e9n trabaja.", + "multiple_jobs_check": "Tengo m\u00faltiples trabajos o mi c\u00f3nyuge trabaja", "two_jobs_desc": "Marque esta casilla si solo hay dos trabajos en total", "multiple_jobs_not_apply": "Si esto no se aplica, puede continuar al siguiente paso", - "dependents_info": "Si su ingreso total serรก de $ 200,000 o menos ($ 400,000 si estรก casado y presenta una declaraciรณn conjunta), puede reclamar crรฉditos por dependientes.", - "children_under_17": "Hijos calificados menores de 17 aรฑos", + "dependents_info": "Si su ingreso total ser\u00e1 de $ 200,000 o menos ($ 400,000 si est\u00e1 casado y presenta una declaraci\u00f3n conjunta), puede reclamar cr\u00e9ditos por dependientes.", + "children_under_17": "Hijos calificados menores de 17 a\u00f1os", "children_each": "$ 2,000 cada uno", "other_dependents": "Otros dependientes", "other_each": "$ 500 cada uno", - "total_credits": "Crรฉditos totales (Paso 3)", + "total_credits": "Cr\u00e9ditos totales (Paso 3)", "adjustments_info": "Estos ajustes son opcionales. Puede omitirlos si no se aplican.", "other_income": "4(a) Otros ingresos (no provenientes de trabajos)", - "other_income_desc": "Incluya intereses, dividendos, ingresos de jubilaciรณn", + "other_income_desc": "Incluya intereses, dividendos, ingresos de jubilaci\u00f3n", "deductions": "4(b) Deducciones", - "deductions_desc": "Si espera reclamar deducciones distintas de la deducciรณn estรกndar", - "extra_withholding": "4(c) Retenciรณn adicional", - "extra_withholding_desc": "Cualquier impuesto adicional que desee que se le retenga en cada perรญodo de pago", + "deductions_desc": "Si espera reclamar deducciones distintas de la deducci\u00f3n est\u00e1ndar", + "extra_withholding": "4(c) Retenci\u00f3n adicional", + "extra_withholding_desc": "Cualquier impuesto adicional que desee que se le retenga en cada per\u00edodo de pago", "summary_title": "Su Resumen de W-4", "summary_name": "Nombre", "summary_ssn": "SSN", "summary_filing": "Estado Civil", - "summary_credits": "Crรฉditos", - "perjury_declaration": "Bajo pena de perjurio, declaro que este certificado, segรบn mi leal saber y entender, es verdadero, correcto y completo.", + "summary_credits": "Cr\u00e9ditos", + "perjury_declaration": "Bajo pena de perjurio, declaro que este certificado, seg\u00fan mi leal saber y entender, es verdadero, correcto y completo.", "signature_label": "Firma (escriba su nombre completo) *", "signature_hint": "Escriba su nombre completo", "date_label": "Fecha", "status_single": "Soltero/a", "status_married": "Casado/a", "status_head": "Cabeza de familia", - "back": "Atrรกs", + "back": "Atr\u00e1s", "continue": "Continuar", "submit": "Enviar Formulario", "step_counter": "Paso {current} de {total}", "hints": { "first_name": "Juan", - "last_name": "Pรฉrez", + "last_name": "P\u00e9rez", "ssn": "XXX-XX-XXXX", "zero": "$ 0", "email": "juan.perez@ejemplo.com", @@ -790,22 +959,22 @@ }, "i9": { "title": "Formulario I-9", - "subtitle": "Verificaciรณn de Elegibilidad de Empleo", - "submitted_title": "ยกFormulario I-9 enviado!", - "submitted_desc": "Su verificaciรณn de elegibilidad de empleo ha sido enviada.", - "back": "Atrรกs", + "subtitle": "Verificaci\u00f3n de Elegibilidad de Empleo", + "submitted_title": "\u00a1Formulario I-9 enviado!", + "submitted_desc": "Su verificaci\u00f3n de elegibilidad de empleo ha sido enviada.", + "back": "Atr\u00e1s", "continue": "Continuar", "submit": "Enviar Formulario", "step_label": "Paso $current de $total", "steps": { - "personal": "Informaciรณn Personal", + "personal": "Informaci\u00f3n Personal", "personal_sub": "Nombre y detalles de contacto", - "address": "Direcciรณn", - "address_sub": "Su direcciรณn actual", - "citizenship": "Estado de Ciudadanรญa", - "citizenship_sub": "Verificaciรณn de autorizaciรณn de trabajo", + "address": "Direcci\u00f3n", + "address_sub": "Su direcci\u00f3n actual", + "citizenship": "Estado de Ciudadan\u00eda", + "citizenship_sub": "Verificaci\u00f3n de autorizaci\u00f3n de trabajo", "review": "Revisar y Firmar", - "review_sub": "Confirme su informaciรณn" + "review_sub": "Confirme su informaci\u00f3n" }, "fields": { "first_name": "Nombre *", @@ -814,41 +983,41 @@ "other_last_names": "Otros apellidos", "maiden_name": "Apellido de soltera (si hay)", "dob": "Fecha de Nacimiento *", - "ssn": "Nรบmero de Seguro Social *", - "email": "Correo electrรณnico", - "phone": "Nรบmero de telรฉfono", - "address_long": "Direcciรณn (Nรบmero y nombre de la calle) *", - "apt": "Nรบm. de apartamento", + "ssn": "N\u00famero de Seguro Social *", + "email": "Correo electr\u00f3nico", + "phone": "N\u00famero de tel\u00e9fono", + "address_long": "Direcci\u00f3n (N\u00famero y nombre de la calle) *", + "apt": "N\u00fam. de apartamento", "city": "Ciudad o Pueblo *", "state": "Estado *", - "zip": "Cรณdigo Postal *", + "zip": "C\u00f3digo Postal *", "attestation": "Doy fe, bajo pena de perjurio, de que soy (marque una de las siguientes casillas):", "citizen": "1. Ciudadano de los Estados Unidos", "noncitizen": "2. Nacional no ciudadano de los Estados Unidos", "permanent_resident": "3. Residente permanente legal", - "uscis_number_label": "Nรบmero USCIS", + "uscis_number_label": "N\u00famero USCIS", "alien": "4. Un extranjero autorizado para trabajar", - "admission_number": "Nรบmero USCIS/Admisiรณn", - "passport": "Nรบmero de pasaporte extranjero", - "country": "Paรญs de emisiรณn", + "admission_number": "N\u00famero USCIS/Admisi\u00f3n", + "passport": "N\u00famero de pasaporte extranjero", + "country": "Pa\u00eds de emisi\u00f3n", "summary_title": "Resumen", "summary_name": "Nombre", - "summary_address": "Direcciรณn", + "summary_address": "Direcci\u00f3n", "summary_ssn": "SSN", - "summary_citizenship": "Ciudadanรญa", + "summary_citizenship": "Ciudadan\u00eda", "status_us_citizen": "Ciudadano de los EE. UU.", "status_noncitizen": "Nacional no ciudadano", "status_permanent_resident": "Residente permanente", "status_alien": "Extranjero autorizado para trabajar", "status_unknown": "Desconocido", - "preparer": "Utilicรฉ un preparador o traductor", - "warning": "Soy consciente de que la ley federal prevรฉ penas de prisiรณn y/o multas por declaraciones falsas o uso de documentos falsos en relaciรณn con la cumplimentaciรณn de este formulario.", + "preparer": "Utilic\u00e9 un preparador o traductor", + "warning": "Soy consciente de que la ley federal prev\u00e9 penas de prisi\u00f3n y/o multas por declaraciones falsas o uso de documentos falsos en relaci\u00f3n con la cumplimentaci\u00f3n de este formulario.", "signature_label": "Firma (escriba su nombre completo) *", "signature_hint": "Escriba su nombre completo", "date_label": "Fecha", "hints": { "first_name": "Juan", - "last_name": "Pรฉrez", + "last_name": "P\u00e9rez", "middle_initial": "J", "dob": "MM/DD/YYYY", "ssn": "XXX-XX-XXXX", @@ -867,7 +1036,7 @@ "staff_documents": { "title": "Documentos", "verification_card": { - "title": "Verificaciรณn de Documentos", + "title": "Verificaci\u00f3n de Documentos", "progress": "$completed/$total Completado" }, "list": { @@ -892,16 +1061,16 @@ "active": "Cumplimiento Activo" }, "card": { - "expires_in_days": "Expira en $days dรญas - Renovar ahora", + "expires_in_days": "Expira en $days d\u00edas - Renovar ahora", "expired": "Expirado - Renovar ahora", "verified": "Verificado", "expiring_soon": "Expira Pronto", "exp": "Exp: $date", "upload_button": "Subir Certificado", - "edit_expiry": "Editar Fecha de Expiraciรณn", + "edit_expiry": "Editar Fecha de Expiraci\u00f3n", "remove": "Eliminar Certificado", "renew": "Renovar", - "opened_snackbar": "Certificado abierto en nueva pestaรฑa" + "opened_snackbar": "Certificado abierto en nueva pesta\u00f1a" }, "add_more": { "title": "Agregar Otro Certificado", @@ -909,7 +1078,7 @@ }, "upload_modal": { "title": "Subir Certificado", - "expiry_label": "Fecha de Expiraciรณn (Opcional)", + "expiry_label": "Fecha de Expiraci\u00f3n (Opcional)", "select_date": "Seleccionar fecha", "upload_file": "Subir Archivo", "drag_drop": "Arrastra y suelta o haz clic para subir", @@ -918,32 +1087,32 @@ "save": "Guardar Certificado" }, "delete_modal": { - "title": "ยฟEliminar Certificado?", - "message": "Esta acciรณn no se puede deshacer.", + "title": "\u00bfEliminar Certificado?", + "message": "Esta acci\u00f3n no se puede deshacer.", "cancel": "Cancelar", "confirm": "Eliminar" } }, "staff_profile_attire": { - "title": "Vestimenta", + "title": "Verificar Vestimenta", "info_card": { "title": "Tu Vestuario", - "description": "Selecciona los artรญculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario." + "description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario." }, "status": { "required": "REQUERIDO", - "add_photo": "Aรฑadir Foto", - "added": "Aรฑadido", - "pending": "โณ Verificaciรณn pendiente" + "add_photo": "A\u00f1adir Foto", + "added": "A\u00f1adido", + "pending": "\u23f3 Verificaci\u00f3n pendiente" }, - "attestation": "Certifico que poseo estos artรญculos y los usarรฉ en mis turnos. Entiendo que los artรญculos estรกn pendientes de verificaciรณn por el gerente en mi primer turno.", + "attestation": "Certifico que poseo estos art\u00edculos y los usar\u00e9 en mis turnos. Entiendo que los art\u00edculos est\u00e1n pendientes de verificaci\u00f3n por el gerente en mi primer turno.", "actions": { "save": "Guardar Vestimenta" }, "validation": { - "select_required": "โœ“ Seleccionar todos los artรญculos requeridos", - "upload_required": "โœ“ Subir fotos de artรญculos requeridos", - "accept_attestation": "โœ“ Aceptar certificaciรณn" + "select_required": "\u2713 Seleccionar todos los art\u00edculos requeridos", + "upload_required": "\u2713 Subir fotos de art\u00edculos requeridos", + "accept_attestation": "\u2713 Aceptar certificaci\u00f3n" } }, "staff_shifts": { @@ -961,17 +1130,17 @@ }, "filter": { "all": "Todos los Empleos", - "one_day": "Un Dรญa", - "multi_day": "Multidรญa", + "one_day": "Un D\u00eda", + "multi_day": "Multid\u00eda", "long_term": "Largo Plazo" }, "status": { "confirmed": "CONFIRMADO", - "act_now": "ACTรšA AHORA", + "act_now": "ACT\u00daA AHORA", "swap_requested": "INTERCAMBIO SOLICITADO", "completed": "COMPLETADO", - "no_show": "NO ASISTIร“", - "pending_warning": "Por favor confirma la asignaciรณn" + "no_show": "NO ASISTI\u00d3", + "pending_warning": "Por favor confirma la asignaci\u00f3n" }, "action": { "decline": "Rechazar", @@ -980,7 +1149,7 @@ }, "details": { "additional": "DETALLES ADICIONALES", - "days": "$days Dรญas", + "days": "$days D\u00edas", "exp_total": "(total est. \\$$amount)", "pending_time": "Pendiente hace $time" }, @@ -995,12 +1164,12 @@ "start_time": "HORA DE INICIO", "end_time": "HORA DE FIN", "base_rate": "Tarifa base", - "duration": "Duraciรณn", + "duration": "Duraci\u00f3n", "est_total": "Total est.", "hours_label": "$count horas", - "location": "UBICACIร“N", + "location": "UBICACI\u00d3N", "tbd": "TBD", - "get_direction": "Obtener direcciรณn", + "get_direction": "Obtener direcci\u00f3n", "break_title": "DESCANSO", "paid": "Pagado", "unpaid": "No pagado", @@ -1008,7 +1177,7 @@ "hourly_rate": "Tarifa por hora", "hours": "Horas", "open_in_maps": "Abrir en Mapas", - "job_description": "DESCRIPCIร“N DEL TRABAJO", + "job_description": "DESCRIPCI\u00d3N DEL TRABAJO", "cancel_shift": "CANCELAR TURNO", "clock_in": "ENTRADA", "decline": "RECHAZAR", @@ -1016,22 +1185,22 @@ "apply_now": "SOLICITAR AHORA", "book_dialog": { "title": "Reservar turno", - "message": "ยฟDesea reservar este turno al instante?" + "message": "\u00bfDesea reservar este turno al instante?" }, "decline_dialog": { "title": "Rechazar turno", - "message": "ยฟEstรก seguro de que desea rechazar este turno? Se ocultarรก de sus trabajos disponibles." + "message": "\u00bfEst\u00e1 seguro de que desea rechazar este turno? Se ocultar\u00e1 de sus trabajos disponibles." }, "cancel_dialog": { "title": "Cancelar turno", - "message": "ยฟEstรก seguro de que desea cancelar este turno?" + "message": "\u00bfEst\u00e1 seguro de que desea cancelar este turno?" }, "applying_dialog": { "title": "Solicitando" } }, "card": { - "just_now": "Reciรฉn", + "just_now": "Reci\u00e9n", "assigned": "Asignado hace $time", "accept_shift": "Aceptar turno", "decline_shift": "Rechazar turno" @@ -1039,31 +1208,46 @@ "my_shifts_tab": { "confirm_dialog": { "title": "Aceptar Turno", - "message": "ยฟEstรกs seguro de que quieres aceptar este turno?", - "success": "ยกTurno confirmado!" + "message": "\u00bfEst\u00e1s seguro de que quieres aceptar este turno?", + "success": "\u00a1Turno confirmado!" }, "decline_dialog": { "title": "Rechazar Turno", - "message": "ยฟEstรกs seguro de que quieres rechazar este turno? Esta acciรณn no se puede deshacer.", + "message": "\u00bfEst\u00e1s seguro de que quieres rechazar este turno? Esta acci\u00f3n no se puede deshacer.", "success": "Turno rechazado." }, "sections": { - "awaiting": "Esperando Confirmaciรณn", + "awaiting": "Esperando Confirmaci\u00f3n", "cancelled": "Turnos Cancelados", "confirmed": "Turnos Confirmados" }, "empty": { "title": "Sin turnos esta semana", - "subtitle": "Intenta buscar nuevos trabajos en la pestaรฑa Buscar" + "subtitle": "Intenta buscar nuevos trabajos en la pesta\u00f1a Buscar" }, "date": { "today": "Hoy", - "tomorrow": "Maรฑana" + "tomorrow": "Ma\u00f1ana" }, "card": { "cancelled": "CANCELADO", - "compensation": "โ€ข Compensaciรณn de 4h" + "compensation": "\u2022 Compensaci\u00f3n de 4h" } + }, + "find_shifts": { + "search_hint": "Buscar trabajos, ubicaci\u00f3n...", + "filter_all": "Todos", + "filter_one_day": "Un d\u00eda", + "filter_multi_day": "Varios d\u00edas", + "filter_long_term": "Largo plazo", + "no_jobs_title": "No hay trabajos disponibles", + "no_jobs_subtitle": "Vuelve m\u00e1s tarde", + "application_submitted": "\u00a1Solicitud de turno enviada!", + "radius_filter_title": "Filtro de Radio", + "unlimited_distance": "Distancia ilimitada", + "within_miles": "Dentro de $miles millas", + "clear": "Borrar", + "apply": "Aplicar" } }, "staff_time_card": { @@ -1083,34 +1267,34 @@ }, "errors": { "auth": { - "invalid_credentials": "El correo electrรณnico o la contraseรฑa que ingresaste es incorrecta.", - "account_exists": "Ya existe una cuenta con este correo electrรณnico. Intenta iniciar sesiรณn.", - "session_expired": "Tu sesiรณn ha expirado. Por favor, inicia sesiรณn de nuevo.", - "user_not_found": "No pudimos encontrar tu cuenta. Por favor, verifica tu correo electrรณnico e intenta de nuevo.", - "unauthorized_app": "Esta cuenta no estรก autorizada para esta aplicaciรณn.", - "weak_password": "Por favor, elige una contraseรฑa mรกs segura con al menos 8 caracteres.", + "invalid_credentials": "El correo electr\u00f3nico o la contrase\u00f1a que ingresaste es incorrecta.", + "account_exists": "Ya existe una cuenta con este correo electr\u00f3nico. Intenta iniciar sesi\u00f3n.", + "session_expired": "Tu sesi\u00f3n ha expirado. Por favor, inicia sesi\u00f3n de nuevo.", + "user_not_found": "No pudimos encontrar tu cuenta. Por favor, verifica tu correo electr\u00f3nico e intenta de nuevo.", + "unauthorized_app": "Esta cuenta no est\u00e1 autorizada para esta aplicaci\u00f3n.", + "weak_password": "Por favor, elige una contrase\u00f1a m\u00e1s segura con al menos 8 caracteres.", "sign_up_failed": "No pudimos crear tu cuenta. Por favor, intenta de nuevo.", - "sign_in_failed": "No pudimos iniciar sesiรณn. Por favor, intenta de nuevo.", - "not_authenticated": "Por favor, inicia sesiรณn para continuar.", - "passwords_dont_match": "Las contraseรฑas no coinciden", - "password_mismatch": "Este correo ya estรก registrado. Por favor, usa la contraseรฑa correcta o toca 'Olvidรฉ mi contraseรฑa' para restablecerla.", - "google_only_account": "Este correo estรก registrado con Google. Por favor, usa 'Olvidรฉ mi contraseรฑa' para establecer una contraseรฑa, luego intenta registrarte de nuevo con la misma informaciรณn." + "sign_in_failed": "No pudimos iniciar sesi\u00f3n. Por favor, intenta de nuevo.", + "not_authenticated": "Por favor, inicia sesi\u00f3n para continuar.", + "passwords_dont_match": "Las contrase\u00f1as no coinciden", + "password_mismatch": "Este correo ya est\u00e1 registrado. Por favor, usa la contrase\u00f1a correcta o toca 'Olvid\u00e9 mi contrase\u00f1a' para restablecerla.", + "google_only_account": "Este correo est\u00e1 registrado con Google. Por favor, usa 'Olvid\u00e9 mi contrase\u00f1a' para establecer una contrase\u00f1a, luego intenta registrarte de nuevo con la misma informaci\u00f3n." }, "hub": { - "has_orders": "Este hub tiene รณrdenes activas y no puede ser eliminado.", + "has_orders": "Este hub tiene \u00f3rdenes activas y no puede ser eliminado.", "not_found": "El hub que buscas no existe.", "creation_failed": "No pudimos crear el hub. Por favor, intenta de nuevo." }, "order": { - "missing_hub": "Por favor, selecciona una ubicaciรณn para tu orden.", + "missing_hub": "Por favor, selecciona una ubicaci\u00f3n para tu orden.", "missing_vendor": "Por favor, selecciona un proveedor para tu orden.", "creation_failed": "No pudimos crear tu orden. Por favor, intenta de nuevo.", "shift_creation_failed": "No pudimos programar el turno. Por favor, intenta de nuevo.", - "missing_business": "No se pudo cargar tu perfil de empresa. Por favor, inicia sesiรณn de nuevo." + "missing_business": "No se pudo cargar tu perfil de empresa. Por favor, inicia sesi\u00f3n de nuevo." }, "profile": { - "staff_not_found": "No se pudo cargar tu perfil. Por favor, inicia sesiรณn de nuevo.", - "business_not_found": "No se pudo cargar tu perfil de empresa. Por favor, inicia sesiรณn de nuevo.", + "staff_not_found": "No se pudo cargar tu perfil. Por favor, inicia sesi\u00f3n de nuevo.", + "business_not_found": "No se pudo cargar tu perfil de empresa. Por favor, inicia sesi\u00f3n de nuevo.", "update_failed": "No pudimos actualizar tu perfil. Por favor, intenta de nuevo." }, "shift": { @@ -1119,26 +1303,291 @@ "no_active_shift": "No tienes un turno activo para registrar salida." }, "generic": { - "unknown": "Algo saliรณ mal. Por favor, intenta de nuevo.", - "no_connection": "Sin conexiรณn a internet. Por favor, verifica tu red e intenta de nuevo.", - "server_error": "Error del servidor. Intรฉntalo de nuevo mรกs tarde.", - "service_unavailable": "El servicio no estรก disponible actualmente." + "unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.", + "no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.", + "server_error": "Error del servidor. Int\u00e9ntalo de nuevo m\u00e1s tarde.", + "service_unavailable": "El servicio no est\u00e1 disponible actualmente." } }, + "staff_privacy_security": { + "title": "Privacidad y Seguridad", + "privacy_section": "Privacidad", + "legal_section": "Legal", + "profile_visibility": { + "title": "Visibilidad del Perfil", + "subtitle": "Deja que los clientes vean tu perfil" + }, + "terms_of_service": { + "title": "T\u00e9rminos de Servicio" + }, + "privacy_policy": { + "title": "Pol\u00edtica de Privacidad" + }, + "success": { + "profile_visibility_updated": "\u00a1Visibilidad del perfil actualizada exitosamente!" + } + }, + "staff_faqs": { + "title": "Preguntas Frecuentes", + "search_placeholder": "Buscar preguntas...", + "no_results": "No se encontraron preguntas coincidentes", + "contact_support": "Contactar Soporte" + }, "success": { "hub": { - "created": "ยกHub creado exitosamente!", - "deleted": "ยกHub eliminado exitosamente!", - "nfc_assigned": "ยกEtiqueta NFC asignada exitosamente!" + "created": "\u00a1Hub creado exitosamente!", + "updated": "\u00a1Hub actualizado exitosamente!", + "deleted": "\u00a1Hub eliminado exitosamente!", + "nfc_assigned": "\u00a1Etiqueta NFC asignada exitosamente!" }, "order": { - "created": "ยกOrden creada exitosamente!" + "created": "\u00a1Orden creada exitosamente!" }, "profile": { - "updated": "ยกPerfil actualizado con รฉxito!" + "updated": "\u00a1Perfil actualizado con \u00e9xito!" }, "availability": { - "updated": "Disponibilidad actualizada con รฉxito" + "updated": "Disponibilidad actualizada con \u00e9xito" + } + }, + "client_reports": { + "title": "Torre de Control de Personal", + "tabs": { + "today": "Hoy", + "week": "Semana", + "month": "Mes", + "quarter": "Trimestre" + }, + "metrics": { + "total_hrs": { + "label": "Total de Horas", + "badge": "Este per\u00edodo" + }, + "ot_hours": { + "label": "Horas Extra", + "badge": "5.1% del total" + }, + "total_spend": { + "label": "Gasto Total", + "badge": "\u2193 8% vs semana pasada" + }, + "fill_rate": { + "label": "Tasa de Cobertura", + "badge": "\u2191 2% de mejora" + }, + "avg_fill_time": { + "label": "Tiempo Promedio de Llenado", + "badge": "Mejor de la industria" + }, + "no_show_rate": { + "label": "Tasa de Faltas", + "badge": "Bajo el promedio" + } + }, + "quick_reports": { + "title": "Informes R\u00e1pidos", + "export_all": "Exportar Todo", + "two_click_export": "Exportaci\u00f3n en 2 clics", + "cards": { + "daily_ops": "Informe de Ops Diarias", + "spend": "Informe de Gastos", + "coverage": "Informe de Cobertura", + "no_show": "Informe de Faltas", + "forecast": "Informe de Previsi\u00f3n", + "performance": "Informe de Rendimiento" + } + }, + "daily_ops_report": { + "title": "Informe de Ops Diarias", + "subtitle": "Seguimiento de turnos en tiempo real", + "metrics": { + "scheduled": { + "label": "Programado", + "sub_value": "turnos" + }, + "workers": { + "label": "Trabajadores", + "sub_value": "confirmados" + }, + "in_progress": { + "label": "En Progreso", + "sub_value": "activos ahora" + }, + "completed": { + "label": "Completado", + "sub_value": "hechos hoy" + } + }, + "all_shifts_title": "TODOS LOS TURNOS", + "no_shifts_today": "No hay turnos programados para hoy", + "shift_item": { + "time": "Hora", + "workers": "Trabajadores", + "rate": "Tarifa" + }, + "statuses": { + "processing": "Procesando", + "filling": "Llenando", + "confirmed": "Confirmado", + "completed": "Completado" + }, + "placeholders": { + "export_message": "Exportando Informe de Ops Diarias (Marcador de posici\u00f3n)" + } + }, + "spend_report": { + "title": "Informe de Gastos", + "subtitle": "An\u00e1lisis y desglose de costos", + "summary": { + "total_spend": "Gasto Total", + "avg_daily": "Promedio Diario", + "this_week": "Esta semana", + "per_day": "Por d\u00eda" + }, + "chart_title": "Tendencia de Gasto Diario", + "charts": { + "mon": "Lun", + "tue": "Mar", + "wed": "Mi\u00e9", + "thu": "Jue", + "fri": "Vie", + "sat": "S\u00e1b", + "sun": "Dom" + }, + "spend_by_industry": "Gasto por Industria", + "industries": { + "hospitality": "Hosteler\u00eda", + "events": "Eventos", + "retail": "Venta minorista" + }, + "percent_total": "$percent% del total", + "no_industry_data": "No hay datos de la industria disponibles", + "placeholders": { + "export_message": "Exportando Informe de Gastos (Marcador de posici\u00f3n)" + } + }, + "forecast_report": { + "title": "Informe de Previsi\u00f3n", + "subtitle": "Proyecci\u00f3n pr\u00f3ximas 4 semanas", + "metrics": { + "four_week_forecast": "Previsi\u00f3n 4 Semanas", + "avg_weekly": "Promedio Semanal", + "total_shifts": "Total de Turnos", + "total_hours": "Total de Horas" + }, + "badges": { + "total_projected": "Total proyectado", + "per_week": "Por semana", + "scheduled": "Programado", + "worker_hours": "Horas de trabajo" + }, + "chart_title": "Previsi\u00f3n de Gastos", + "weekly_breakdown": { + "title": "DESGLOSE SEMANAL", + "week": "Semana $index", + "shifts": "Turnos", + "hours": "Horas", + "avg_shift": "Prom./Turno" + }, + "buttons": { + "export": "Exportar" + }, + "empty_state": "No hay proyecciones disponibles", + "placeholders": { + "export_message": "Exportando Informe de Previsi\u00f3n (Marcador de posici\u00f3n)" + } + }, + "performance_report": { + "title": "Informe de Rendimiento", + "subtitle": "M\u00e9tricas clave y comparativas", + "overall_score": { + "title": "Puntuaci\u00f3n de Rendimiento General", + "excellent": "Excelente", + "good": "Bueno", + "needs_work": "Necesita Mejorar" + }, + "kpis_title": "INDICADORES CLAVE DE RENDIMIENTO (KPI)", + "kpis": { + "fill_rate": "Tasa de Llenado", + "completion_rate": "Tasa de Finalizaci\u00f3n", + "on_time_rate": "Tasa de Puntualidad", + "avg_fill_time": "Tiempo Promedio de Llenado", + "target_prefix": "Objetivo: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", + "met": "\u2713 Cumplido", + "close": "\u2192 Cerca", + "miss": "\u2717 Fallido" + }, + "additional_metrics_title": "M\u00c9TRICAS ADICIONALES", + "additional_metrics": { + "total_shifts": "Total de Turnos", + "no_show_rate": "Tasa de Faltas", + "worker_pool": "Grupo de Trabajadores", + "avg_rating": "Calificaci\u00f3n Promedio" + }, + "placeholders": { + "export_message": "Exportando Informe de Rendimiento (Marcador de posici\u00f3n)" + } + }, + "no_show_report": { + "title": "Informe de Faltas", + "subtitle": "Seguimiento de confiabilidad", + "metrics": { + "no_shows": "Faltas", + "rate": "Tasa", + "workers": "Trabajadores" + }, + "workers_list_title": "TRABAJADORES CON FALTAS", + "no_show_count": "$count falta(s)", + "latest_incident": "\u00daltimo incidente", + "risks": { + "high": "Riesgo Alto", + "medium": "Riesgo Medio", + "low": "Riesgo Bajo" + }, + "empty_state": "No hay trabajadores se\u00f1alados por faltas", + "placeholders": { + "export_message": "Exportando Informe de Faltas (Marcador de posici\u00f3n)" + } + }, + "coverage_report": { + "title": "Informe de Cobertura", + "subtitle": "Niveles de personal y brechas", + "metrics": { + "avg_coverage": "Cobertura Promedio", + "full": "Completa", + "needs_help": "Necesita Ayuda" + }, + "next_7_days": "PR\u00d3XIMOS 7 D\u00cdAS", + "empty_state": "No hay turnos programados", + "shift_item": { + "confirmed_workers": "$confirmed/$needed trabajadores confirmados", + "spots_remaining": "$count puestos restantes", + "one_spot_remaining": "1 puesto restante", + "fully_staffed": "Totalmente cubierto" + }, + "placeholders": { + "export_message": "Exportando Informe de Cobertura (Marcador de posici\u00f3n)" + } + } + }, + "client_coverage": { + "worker_row": { + "verify": "Verificar", + "verified_message": "Vestimenta del trabajador verificada para $name" + } + }, + "staff_payments": { + "early_pay": { + "title": "Pago Anticipado", + "available_label": "Disponible para Retirar", + "select_amount": "Seleccionar Monto", + "hint_amount": "Ingrese el monto a retirar", + "deposit_to": "Dep\u00f3sito instant\u00e1neo a:", + "confirm_button": "Confirmar Retiro", + "success_message": "\u00a1Solicitud de retiro enviada!", + "fee_notice": "Puede aplicarse una peque\u00f1a tarifa de \\$1.99 para transferencias instant\u00e1neas." } } } \ No newline at end of file diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 7afa4c97..378eb395 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -6,7 +6,6 @@ /// They will implement interfaces defined in feature packages once those are created. library; - export 'src/data_connect_module.dart'; export 'src/session/client_session_store.dart'; @@ -17,3 +16,34 @@ export 'src/services/mixins/session_handler_mixin.dart'; export 'src/session/staff_session_store.dart'; export 'src/services/mixins/data_error_handler.dart'; + +// Export Staff Connector repositories and use cases +export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart'; +export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart'; +export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart'; +export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; + +// Export Reports Connector +export 'src/connectors/reports/domain/repositories/reports_connector_repository.dart'; +export 'src/connectors/reports/data/repositories/reports_connector_repository_impl.dart'; + +// Export Shifts Connector +export 'src/connectors/shifts/domain/repositories/shifts_connector_repository.dart'; +export 'src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart'; + +// Export Hubs Connector +export 'src/connectors/hubs/domain/repositories/hubs_connector_repository.dart'; +export 'src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart'; + +// Export Billing Connector +export 'src/connectors/billing/domain/repositories/billing_connector_repository.dart'; +export 'src/connectors/billing/data/repositories/billing_connector_repository_impl.dart'; + +// Export Coverage Connector +export 'src/connectors/coverage/domain/repositories/coverage_connector_repository.dart'; +export 'src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart new file mode 100644 index 00000000..7c955b71 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart @@ -0,0 +1,271 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:firebase_data_connect/src/core/ref.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/billing_connector_repository.dart'; + +/// Implementation of [BillingConnectorRepository]. +class BillingConnectorRepositoryImpl implements BillingConnectorRepository { + BillingConnectorRepositoryImpl({ + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future> getBankAccounts({required String businessId}) async { + return _service.run(() async { + final QueryResult result = await _service.connector + .getAccountsByOwnerId(ownerId: businessId) + .execute(); + + return result.data.accounts.map(_mapBankAccount).toList(); + }); + } + + @override + Future getCurrentBillAmount({required String businessId}) async { + return _service.run(() async { + final QueryResult result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); + + return result.data.invoices + .map(_mapInvoice) + .where((Invoice i) => i.status == InvoiceStatus.open) + .fold(0.0, (double sum, Invoice item) => sum + item.totalAmount); + }); + } + + @override + Future> getInvoiceHistory({required String businessId}) async { + return _service.run(() async { + final QueryResult result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .limit(20) + .execute(); + + return result.data.invoices + .map(_mapInvoice) + .where((Invoice i) => i.status == InvoiceStatus.paid) + .toList(); + }); + } + + @override + Future> getPendingInvoices({required String businessId}) async { + return _service.run(() async { + final QueryResult result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); + + return result.data.invoices + .map(_mapInvoice) + .where((Invoice i) => + i.status != InvoiceStatus.paid) + .toList(); + }); + } + + @override + Future> getSpendingBreakdown({ + required String businessId, + required BillingPeriod period, + }) async { + return _service.run(() async { + final DateTime now = DateTime.now(); + final DateTime start; + final DateTime end; + + if (period == BillingPeriod.week) { + final int daysFromMonday = now.weekday - DateTime.monday; + final DateTime monday = DateTime(now.year, now.month, now.day) + .subtract(Duration(days: daysFromMonday)); + start = monday; + end = monday.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + } else { + start = DateTime(now.year, now.month, 1); + end = DateTime(now.year, now.month + 1, 0, 23, 59, 59); + } + + final QueryResult result = await _service.connector + .listShiftRolesByBusinessAndDatesSummary( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); + + final List shiftRoles = result.data.shiftRoles; + if (shiftRoles.isEmpty) return []; + + final Map summary = {}; + for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role in shiftRoles) { + final String roleId = role.roleId; + final String roleName = role.role.name; + final double hours = role.hours ?? 0.0; + final double totalValue = role.totalValue ?? 0.0; + + final _RoleSummary? existing = summary[roleId]; + if (existing == null) { + summary[roleId] = _RoleSummary( + roleId: roleId, + roleName: roleName, + totalHours: hours, + totalValue: totalValue, + ); + } else { + summary[roleId] = existing.copyWith( + totalHours: existing.totalHours + hours, + totalValue: existing.totalValue + totalValue, + ); + } + } + + return summary.values + .map((_RoleSummary item) => InvoiceItem( + id: item.roleId, + invoiceId: item.roleId, + staffId: item.roleName, + workHours: item.totalHours, + rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, + amount: item.totalValue, + )) + .toList(); + }); + } + + @override + Future approveInvoice({required String id}) async { + return _service.run(() async { + await _service.connector + .updateInvoice(id: id) + .status(dc.InvoiceStatus.APPROVED) + .execute(); + }); + } + + @override + Future disputeInvoice({required String id, required String reason}) async { + return _service.run(() async { + await _service.connector + .updateInvoice(id: id) + .status(dc.InvoiceStatus.DISPUTED) + .disputeReason(reason) + .execute(); + }); + } + + // --- MAPPERS --- + + Invoice _mapInvoice(dynamic invoice) { + final List rolesData = invoice.roles is List ? invoice.roles : []; + final List workers = rolesData.map((dynamic r) { + final Map role = r as Map; + + // Handle various possible key naming conventions in the JSON data + final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown'; + final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff'; + final double amount = (role['amount'] as num?)?.toDouble() ?? + (role['totalValue'] as num?)?.toDouble() ?? 0.0; + final double hours = (role['hours'] as num?)?.toDouble() ?? + (role['workHours'] as num?)?.toDouble() ?? + (role['totalHours'] as num?)?.toDouble() ?? 0.0; + final double rate = (role['rate'] as num?)?.toDouble() ?? + (role['hourlyRate'] as num?)?.toDouble() ?? 0.0; + + final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time']; + final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time']; + + return InvoiceWorker( + name: name, + role: roleTitle, + amount: amount, + hours: hours, + rate: rate, + checkIn: _service.toDateTime(checkInVal), + checkOut: _service.toDateTime(checkOutVal), + breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0, + avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'], + ); + }).toList(); + + return Invoice( + id: invoice.id, + eventId: invoice.orderId, + businessId: invoice.businessId, + status: _mapInvoiceStatus(invoice.status.stringValue), + totalAmount: invoice.amount, + workAmount: invoice.amount, + addonsAmount: invoice.otherCharges ?? 0, + invoiceNumber: invoice.invoiceNumber, + issueDate: _service.toDateTime(invoice.issueDate)!, + title: invoice.order?.eventName, + clientName: invoice.business?.businessName, + locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address, + staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0), + totalHours: _calculateTotalHours(rolesData), + workers: workers, + ); + } + + double _calculateTotalHours(List roles) { + return roles.fold(0.0, (sum, role) { + final hours = role['hours'] ?? role['workHours'] ?? role['totalHours']; + if (hours is num) return sum + hours.toDouble(); + return sum; + }); + } + + BusinessBankAccount _mapBankAccount(dynamic account) { + return BusinessBankAccountAdapter.fromPrimitives( + id: account.id, + bank: account.bank, + last4: account.last4, + isPrimary: account.isPrimary ?? false, + expiryTime: _service.toDateTime(account.expiryTime), + ); + } + + InvoiceStatus _mapInvoiceStatus(String status) { + switch (status) { + case 'PAID': + return InvoiceStatus.paid; + case 'OVERDUE': + return InvoiceStatus.overdue; + case 'DISPUTED': + return InvoiceStatus.disputed; + case 'APPROVED': + return InvoiceStatus.verified; + default: + return InvoiceStatus.open; + } + } +} + +class _RoleSummary { + const _RoleSummary({ + required this.roleId, + required this.roleName, + required this.totalHours, + required this.totalValue, + }); + + final String roleId; + final String roleName; + final double totalHours; + final double totalValue; + + _RoleSummary copyWith({ + double? totalHours, + double? totalValue, + }) { + return _RoleSummary( + roleId: roleId, + roleName: roleName, + totalHours: totalHours ?? this.totalHours, + totalValue: totalValue ?? this.totalValue, + ); + } +} + diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart new file mode 100644 index 00000000..4d4b0464 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart @@ -0,0 +1,30 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for billing connector operations. +/// +/// This acts as a buffer layer between the domain repository and the Data Connect SDK. +abstract interface class BillingConnectorRepository { + /// Fetches bank accounts associated with the business. + Future> getBankAccounts({required String businessId}); + + /// Fetches the current bill amount for the period. + Future getCurrentBillAmount({required String businessId}); + + /// Fetches historically paid invoices. + Future> getInvoiceHistory({required String businessId}); + + /// Fetches pending invoices (Open or Disputed). + Future> getPendingInvoices({required String businessId}); + + /// Fetches the breakdown of spending. + Future> getSpendingBreakdown({ + required String businessId, + required BillingPeriod period, + }); + + /// Approves an invoice. + Future approveInvoice({required String id}); + + /// Disputes an invoice. + Future disputeInvoice({required String id, required String reason}); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart new file mode 100644 index 00000000..d4fbea5c --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart @@ -0,0 +1,158 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:firebase_data_connect/src/core/ref.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/coverage_connector_repository.dart'; + +/// Implementation of [CoverageConnectorRepository]. +class CoverageConnectorRepositoryImpl implements CoverageConnectorRepository { + CoverageConnectorRepositoryImpl({ + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future> getShiftsForDate({ + required String businessId, + required DateTime date, + }) async { + return _service.run(() async { + final DateTime start = DateTime(date.year, date.month, date.day); + final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999); + + final QueryResult shiftRolesResult = await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); + + final QueryResult applicationsResult = await _service.connector + .listStaffsApplicationsByBusinessForDay( + businessId: businessId, + dayStart: _service.toTimestamp(start), + dayEnd: _service.toTimestamp(end), + ) + .execute(); + + return _mapCoverageShifts( + shiftRolesResult.data.shiftRoles, + applicationsResult.data.applications, + date, + ); + }); + } + + List _mapCoverageShifts( + List shiftRoles, + List applications, + DateTime date, + ) { + if (shiftRoles.isEmpty && applications.isEmpty) return []; + + final Map groups = {}; + + for (final sr in shiftRoles) { + final String key = '${sr.shiftId}:${sr.roleId}'; + final DateTime? startTime = _service.toDateTime(sr.startTime); + + groups[key] = _CoverageGroup( + shiftId: sr.shiftId, + roleId: sr.roleId, + title: sr.role.name, + location: sr.shift.location ?? sr.shift.locationAddress ?? '', + startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00', + workersNeeded: sr.count, + date: _service.toDateTime(sr.shift.date) ?? date, + workers: [], + ); + } + + for (final app in applications) { + final String key = '${app.shiftId}:${app.roleId}'; + if (!groups.containsKey(key)) { + final DateTime? startTime = _service.toDateTime(app.shiftRole.startTime); + groups[key] = _CoverageGroup( + shiftId: app.shiftId, + roleId: app.roleId, + title: app.shiftRole.role.name, + location: app.shiftRole.shift.location ?? app.shiftRole.shift.locationAddress ?? '', + startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00', + workersNeeded: app.shiftRole.count, + date: _service.toDateTime(app.shiftRole.shift.date) ?? date, + workers: [], + ); + } + + final DateTime? checkIn = _service.toDateTime(app.checkInTime); + groups[key]!.workers.add( + CoverageWorker( + name: app.staff.fullName, + status: _mapWorkerStatus(app.status.stringValue), + checkInTime: checkIn != null ? DateFormat('HH:mm').format(checkIn) : null, + ), + ); + } + + return groups.values + .map((_CoverageGroup g) => CoverageShift( + id: '${g.shiftId}:${g.roleId}', + title: g.title, + location: g.location, + startTime: g.startTime, + workersNeeded: g.workersNeeded, + date: g.date, + workers: g.workers, + )) + .toList(); + } + + CoverageWorkerStatus _mapWorkerStatus(String status) { + switch (status) { + case 'PENDING': + return CoverageWorkerStatus.pending; + case 'REJECTED': + return CoverageWorkerStatus.rejected; + case 'CONFIRMED': + return CoverageWorkerStatus.confirmed; + case 'CHECKED_IN': + return CoverageWorkerStatus.checkedIn; + case 'CHECKED_OUT': + return CoverageWorkerStatus.checkedOut; + case 'LATE': + return CoverageWorkerStatus.late; + case 'NO_SHOW': + return CoverageWorkerStatus.noShow; + case 'COMPLETED': + return CoverageWorkerStatus.completed; + default: + return CoverageWorkerStatus.pending; + } + } +} + +class _CoverageGroup { + _CoverageGroup({ + required this.shiftId, + required this.roleId, + required this.title, + required this.location, + required this.startTime, + required this.workersNeeded, + required this.date, + required this.workers, + }); + + final String shiftId; + final String roleId; + final String title; + final String location; + final String startTime; + final int workersNeeded; + final DateTime date; + final List workers; +} + diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart new file mode 100644 index 00000000..abb993c1 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for coverage connector operations. +/// +/// This acts as a buffer layer between the domain repository and the Data Connect SDK. +abstract interface class CoverageConnectorRepository { + /// Fetches coverage data for a specific date and business. + Future> getShiftsForDate({ + required String businessId, + required DateTime date, + }); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart new file mode 100644 index 00000000..c046918c --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart @@ -0,0 +1,340 @@ +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'dart:convert'; +import 'package:firebase_data_connect/src/core/ref.dart'; +import 'package:http/http.dart' as http; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/hubs_connector_repository.dart'; + +/// Implementation of [HubsConnectorRepository]. +class HubsConnectorRepositoryImpl implements HubsConnectorRepository { + HubsConnectorRepositoryImpl({ + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future> getHubs({required String businessId}) async { + return _service.run(() async { + final String teamId = await _getOrCreateTeamId(businessId); + final QueryResult response = await _service.connector + .getTeamHubsByTeamId(teamId: teamId) + .execute(); + + final QueryResult< + dc.ListTeamHudDepartmentsData, + dc.ListTeamHudDepartmentsVariables + > + deptsResult = await _service.connector.listTeamHudDepartments().execute(); + final Map hubToDept = + {}; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in deptsResult.data.teamHudDepartments) { + if (dep.costCenter != null && + dep.costCenter!.isNotEmpty && + !hubToDept.containsKey(dep.teamHubId)) { + hubToDept[dep.teamHubId] = dep; + } + } + + return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) { + final dc.ListTeamHudDepartmentsTeamHudDepartments? dept = + hubToDept[h.id]; + return Hub( + id: h.id, + businessId: businessId, + name: h.hubName, + address: h.address, + nfcTagId: null, + status: h.isActive ? HubStatus.active : HubStatus.inactive, + costCenter: dept != null + ? CostCenter( + id: dept.id, + name: dept.name, + code: dept.costCenter ?? dept.name, + ) + : null, + ); + }).toList(); + }); + } + + @override + Future createHub({ + required String businessId, + required String name, + required String address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, + }) async { + return _service.run(() async { + final String teamId = await _getOrCreateTeamId(businessId); + final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) + ? await _fetchPlaceAddress(placeId) + : null; + + final OperationResult result = await _service.connector + .createTeamHub( + teamId: teamId, + hubName: name, + address: address, + ) + .placeId(placeId) + .latitude(latitude) + .longitude(longitude) + .city(city ?? placeAddress?.city ?? '') + .state(state ?? placeAddress?.state) + .street(street ?? placeAddress?.street) + .country(country ?? placeAddress?.country) + .zipCode(zipCode ?? placeAddress?.zipCode) + .execute(); + + final String hubId = result.data.teamHub_insert.id; + CostCenter? costCenter; + if (costCenterId != null && costCenterId.isNotEmpty) { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: hubId, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + + return Hub( + id: hubId, + businessId: businessId, + name: name, + address: address, + nfcTagId: null, + status: HubStatus.active, + costCenter: costCenter, + ); + }); + } + + @override + Future updateHub({ + required String businessId, + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, + }) async { + return _service.run(() async { + final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) + ? await _fetchPlaceAddress(placeId) + : null; + + final dc.UpdateTeamHubVariablesBuilder builder = _service.connector.updateTeamHub(id: id); + + if (name != null) builder.hubName(name); + if (address != null) builder.address(address); + if (placeId != null) builder.placeId(placeId); + if (latitude != null) builder.latitude(latitude); + if (longitude != null) builder.longitude(longitude); + if (city != null || placeAddress?.city != null) { + builder.city(city ?? placeAddress?.city); + } + if (state != null || placeAddress?.state != null) { + builder.state(state ?? placeAddress?.state); + } + if (street != null || placeAddress?.street != null) { + builder.street(street ?? placeAddress?.street); + } + if (country != null || placeAddress?.country != null) { + builder.country(country ?? placeAddress?.country); + } + if (zipCode != null || placeAddress?.zipCode != null) { + builder.zipCode(zipCode ?? placeAddress?.zipCode); + } + + await builder.execute(); + + CostCenter? costCenter; + final QueryResult< + dc.ListTeamHudDepartmentsByTeamHubIdData, + dc.ListTeamHudDepartmentsByTeamHubIdVariables + > + deptsResult = await _service.connector + .listTeamHudDepartmentsByTeamHubId(teamHubId: id) + .execute(); + final List depts = + deptsResult.data.teamHudDepartments; + + if (costCenterId == null || costCenterId.isEmpty) { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(null) + .execute(); + } + } else { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } else { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: id, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + } + + return Hub( + id: id, + businessId: businessId, + name: name ?? '', + address: address ?? '', + nfcTagId: null, + status: HubStatus.active, + costCenter: costCenter, + ); + }); + } + + @override + Future deleteHub({required String businessId, required String id}) async { + return _service.run(() async { + final QueryResult ordersRes = await _service.connector + .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id) + .execute(); + + if (ordersRes.data.orders.isNotEmpty) { + throw HubHasOrdersException( + technicalMessage: 'Hub $id has ${ordersRes.data.orders.length} orders', + ); + } + + await _service.connector.deleteTeamHub(id: id).execute(); + }); + } + + // --- HELPERS --- + + Future _getOrCreateTeamId(String businessId) async { + final QueryResult teamsRes = await _service.connector + .getTeamsByOwnerId(ownerId: businessId) + .execute(); + + if (teamsRes.data.teams.isNotEmpty) { + return teamsRes.data.teams.first.id; + } + + // Logic to fetch business details to create a team name if missing + // For simplicity, we assume one exists or we create a generic one + final OperationResult createRes = await _service.connector + .createTeam( + teamName: 'Business Team', + ownerId: businessId, + ownerName: '', + ownerRole: 'OWNER', + ) + .execute(); + + return createRes.data.team_insert.id; + } + + Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async { + final Uri uri = Uri.https( + 'maps.googleapis.com', + '/maps/api/place/details/json', + { + 'place_id': placeId, + 'fields': 'address_component', + 'key': AppConfig.googleMapsApiKey, + }, + ); + try { + final http.Response response = await http.get(uri); + if (response.statusCode != 200) return null; + + final Map payload = json.decode(response.body) as Map; + if (payload['status'] != 'OK') return null; + + final Map? result = payload['result'] as Map?; + final List? components = result?['address_components'] as List?; + if (components == null || components.isEmpty) return null; + + String? streetNumber, route, city, state, country, zipCode; + + for (var entry in components) { + final Map component = entry as Map; + final List types = component['types'] as List? ?? []; + final String? longName = component['long_name'] as String?; + final String? shortName = component['short_name'] as String?; + + if (types.contains('street_number')) { + streetNumber = longName; + } else if (types.contains('route')) { + route = longName; + } else if (types.contains('locality')) { + city = longName; + } else if (types.contains('administrative_area_level_1')) { + state = shortName ?? longName; + } else if (types.contains('country')) { + country = shortName ?? longName; + } else if (types.contains('postal_code')) { + zipCode = longName; + } + } + + final String street = [streetNumber, route] + .where((String? v) => v != null && v.isNotEmpty) + .join(' ') + .trim(); + + return _PlaceAddress( + street: street.isEmpty ? null : street, + city: city, + state: state, + country: country, + zipCode: zipCode, + ); + } catch (_) { + return null; + } + } +} + +class _PlaceAddress { + const _PlaceAddress({ + this.street, + this.city, + this.state, + this.country, + this.zipCode, + }); + + final String? street; + final String? city; + final String? state; + final String? country; + final String? zipCode; +} + diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart new file mode 100644 index 00000000..42a83265 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart @@ -0,0 +1,45 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for hubs connector operations. +/// +/// This acts as a buffer layer between the domain repository and the Data Connect SDK. +abstract interface class HubsConnectorRepository { + /// Fetches the list of hubs for a business. + Future> getHubs({required String businessId}); + + /// Creates a new hub. + Future createHub({ + required String businessId, + required String name, + required String address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, + }); + + /// Updates an existing hub. + Future updateHub({ + required String businessId, + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, + }); + + /// Deletes a hub. + Future deleteHub({required String businessId, required String id}); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart new file mode 100644 index 00000000..c4a04aac --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart @@ -0,0 +1,537 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/reports_connector_repository.dart'; + +/// Implementation of [ReportsConnectorRepository]. +/// +/// Fetches report-related data from the Data Connect backend. +class ReportsConnectorRepositoryImpl implements ReportsConnectorRepository { + /// Creates a new [ReportsConnectorRepositoryImpl]. + ReportsConnectorRepositoryImpl({ + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final QueryResult response = await _service.connector + .listShiftsForDailyOpsByBusiness( + businessId: id, + date: _service.toTimestamp(date), + ) + .execute(); + + final List shifts = response.data.shifts; + + final int scheduledShifts = shifts.length; + int workersConfirmed = 0; + int inProgressShifts = 0; + int completedShifts = 0; + + final List dailyOpsShifts = []; + + for (final dc.ListShiftsForDailyOpsByBusinessShifts shift in shifts) { + workersConfirmed += shift.filled ?? 0; + final String statusStr = shift.status?.stringValue ?? ''; + if (statusStr == 'IN_PROGRESS') inProgressShifts++; + if (statusStr == 'COMPLETED') completedShifts++; + + dailyOpsShifts.add(DailyOpsShift( + id: shift.id, + title: shift.title ?? '', + location: shift.location ?? '', + startTime: shift.startTime?.toDateTime() ?? DateTime.now(), + endTime: shift.endTime?.toDateTime() ?? DateTime.now(), + workersNeeded: shift.workersNeeded ?? 0, + filled: shift.filled ?? 0, + status: statusStr, + )); + } + + return DailyOpsReport( + scheduledShifts: scheduledShifts, + workersConfirmed: workersConfirmed, + inProgressShifts: inProgressShifts, + completedShifts: completedShifts, + shifts: dailyOpsShifts, + ); + }); + } + + @override + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final QueryResult response = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List invoices = response.data.invoices; + + double totalSpend = 0.0; + int paidInvoices = 0; + int pendingInvoices = 0; + int overdueInvoices = 0; + + final List spendInvoices = []; + final Map dailyAggregates = {}; + final Map industryAggregates = {}; + + for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) { + final double amount = (inv.amount ?? 0.0).toDouble(); + totalSpend += amount; + + final String statusStr = inv.status.stringValue; + if (statusStr == 'PAID') { + paidInvoices++; + } else if (statusStr == 'PENDING') { + pendingInvoices++; + } else if (statusStr == 'OVERDUE') { + overdueInvoices++; + } + + final String industry = inv.vendor.serviceSpecialty ?? 'Other'; + industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount; + + final DateTime issueDateTime = inv.issueDate.toDateTime(); + spendInvoices.add(SpendInvoice( + id: inv.id, + invoiceNumber: inv.invoiceNumber ?? '', + issueDate: issueDateTime, + amount: amount, + status: statusStr, + vendorName: inv.vendor.companyName ?? 'Unknown', + industry: industry, + )); + + // Chart data aggregation + final DateTime date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day); + dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount; + } + + // Ensure chart data covers all days in range + final Map completeDailyAggregates = {}; + for (int i = 0; i <= endDate.difference(startDate).inDays; i++) { + final DateTime date = startDate.add(Duration(days: i)); + final DateTime normalizedDate = DateTime(date.year, date.month, date.day); + completeDailyAggregates[normalizedDate] = + dailyAggregates[normalizedDate] ?? 0.0; + } + + final List chartData = completeDailyAggregates.entries + .map((MapEntry e) => SpendChartPoint(date: e.key, amount: e.value)) + .toList() + ..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date)); + + final List industryBreakdown = industryAggregates.entries + .map((MapEntry e) => SpendIndustryCategory( + name: e.key, + amount: e.value, + percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0, + )) + .toList() + ..sort((SpendIndustryCategory a, SpendIndustryCategory b) => b.amount.compareTo(a.amount)); + + final int daysCount = endDate.difference(startDate).inDays + 1; + + return SpendReport( + totalSpend: totalSpend, + averageCost: daysCount > 0 ? totalSpend / daysCount : 0, + paidInvoices: paidInvoices, + pendingInvoices: pendingInvoices, + overdueInvoices: overdueInvoices, + invoices: spendInvoices, + chartData: chartData, + industryBreakdown: industryBreakdown, + ); + }); + } + + @override + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final QueryResult response = await _service.connector + .listShiftsForCoverage( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + final Map dailyStats = {}; + + for (final dc.ListShiftsForCoverageShifts shift in shifts) { + final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final int needed = shift.workersNeeded ?? 0; + final int filled = shift.filled ?? 0; + + totalNeeded += needed; + totalFilled += filled; + + final (int, int) current = dailyStats[date] ?? (0, 0); + dailyStats[date] = (current.$1 + needed, current.$2 + filled); + } + + final List dailyCoverage = dailyStats.entries.map((MapEntry e) { + final int needed = e.value.$1; + final int filled = e.value.$2; + return CoverageDay( + date: e.key, + needed: needed, + filled: filled, + percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0, + ); + }).toList()..sort((CoverageDay a, CoverageDay b) => a.date.compareTo(b.date)); + + return CoverageReport( + overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0, + totalNeeded: totalNeeded, + totalFilled: totalFilled, + dailyCoverage: dailyCoverage, + ); + }); + } + + @override + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final QueryResult response = await _service.connector + .listShiftsForForecastByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List shifts = response.data.shifts; + + double projectedSpend = 0.0; + int projectedWorkers = 0; + double totalHours = 0.0; + final Map dailyStats = {}; + + // Weekly stats: index -> (cost, count, hours) + final Map weeklyStats = { + 0: (0.0, 0, 0.0), + 1: (0.0, 0, 0.0), + 2: (0.0, 0, 0.0), + 3: (0.0, 0, 0.0), + }; + + for (final dc.ListShiftsForForecastByBusinessShifts shift in shifts) { + final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final double cost = (shift.cost ?? 0.0).toDouble(); + final int workers = shift.workersNeeded ?? 0; + final double hoursVal = (shift.hours ?? 0).toDouble(); + final double shiftTotalHours = hoursVal * workers; + + projectedSpend += cost; + projectedWorkers += workers; + totalHours += shiftTotalHours; + + final (double, int) current = dailyStats[date] ?? (0.0, 0); + dailyStats[date] = (current.$1 + cost, current.$2 + workers); + + // Weekly logic + final int diffDays = shiftDate.difference(startDate).inDays; + if (diffDays >= 0) { + final int weekIndex = diffDays ~/ 7; + if (weekIndex < 4) { + final (double, int, double) wCurrent = weeklyStats[weekIndex]!; + weeklyStats[weekIndex] = ( + wCurrent.$1 + cost, + wCurrent.$2 + 1, + wCurrent.$3 + shiftTotalHours, + ); + } + } + } + + final List chartData = dailyStats.entries.map((MapEntry e) { + return ForecastPoint( + date: e.key, + projectedCost: e.value.$1, + workersNeeded: e.value.$2, + ); + }).toList()..sort((ForecastPoint a, ForecastPoint b) => a.date.compareTo(b.date)); + + final List weeklyBreakdown = []; + for (int i = 0; i < 4; i++) { + final (double, int, double) stats = weeklyStats[i]!; + weeklyBreakdown.add(ForecastWeek( + weekNumber: i + 1, + totalCost: stats.$1, + shiftsCount: stats.$2, + hoursCount: stats.$3, + avgCostPerShift: stats.$2 == 0 ? 0.0 : stats.$1 / stats.$2, + )); + } + + final int weeksCount = (endDate.difference(startDate).inDays / 7).ceil(); + final double avgWeeklySpend = weeksCount > 0 ? projectedSpend / weeksCount : 0.0; + + return ForecastReport( + projectedSpend: projectedSpend, + projectedWorkers: projectedWorkers, + averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers, + chartData: chartData, + totalShifts: shifts.length, + totalHours: totalHours, + avgWeeklySpend: avgWeeklySpend, + weeklyBreakdown: weeklyBreakdown, + ); + }); + } + + @override + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final QueryResult response = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + int completedCount = 0; + double totalFillTimeSeconds = 0.0; + int filledShiftsWithTime = 0; + + for (final dc.ListShiftsForPerformanceByBusinessShifts shift in shifts) { + totalNeeded += shift.workersNeeded ?? 0; + totalFilled += shift.filled ?? 0; + if ((shift.status?.stringValue ?? '') == 'COMPLETED') { + completedCount++; + } + + if (shift.filledAt != null && shift.createdAt != null) { + final DateTime createdAt = shift.createdAt!.toDateTime(); + final DateTime filledAt = shift.filledAt!.toDateTime(); + totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; + filledShiftsWithTime++; + } + } + + final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0; + final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0; + final double avgFillTimeHours = filledShiftsWithTime == 0 + ? 0 + : (totalFillTimeSeconds / filledShiftsWithTime) / 3600; + + return PerformanceReport( + fillRate: fillRate, + completionRate: completionRate, + onTimeRate: 95.0, + avgFillTimeHours: avgFillTimeHours, + keyPerformanceIndicators: [ + PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02), + PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05), + PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1), + ], + ); + }); + } + + @override + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + + final QueryResult shiftsResponse = await _service.connector + .listShiftsForNoShowRangeByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList(); + if (shiftIds.isEmpty) { + return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []); + } + + final QueryResult appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + + final List apps = appsResponse.data.applications; + final List noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList(); + final List noShowStaffIds = noShowApps.map((dc.ListApplicationsForNoShowRangeApplications a) => a.staffId).toSet().toList(); + + if (noShowStaffIds.isEmpty) { + return NoShowReport( + totalNoShows: noShowApps.length, + noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0, + flaggedWorkers: [], + ); + } + + final QueryResult staffResponse = await _service.connector + .listStaffForNoShowReport(staffIds: noShowStaffIds) + .execute(); + + final List staffList = staffResponse.data.staffs; + + final List flaggedWorkers = staffList.map((dc.ListStaffForNoShowReportStaffs s) => NoShowWorker( + id: s.id, + fullName: s.fullName ?? '', + noShowCount: s.noShowCount ?? 0, + reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(), + )).toList(); + + return NoShowReport( + totalNoShows: noShowApps.length, + noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0, + flaggedWorkers: flaggedWorkers, + ); + }); + } + + @override + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + + // Use forecast query for hours/cost data + final QueryResult shiftsResponse = await _service.connector + .listShiftsForForecastByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + // Use performance query for avgFillTime (has filledAt + createdAt) + final QueryResult perfResponse = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final QueryResult invoicesResponse = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final List forecastShifts = shiftsResponse.data.shifts; + final List perfShifts = perfResponse.data.shifts; + final List invoices = invoicesResponse.data.invoices; + + // Aggregate hours and fill rate from forecast shifts + double totalHours = 0; + int totalNeeded = 0; + + for (final dc.ListShiftsForForecastByBusinessShifts shift in forecastShifts) { + totalHours += (shift.hours ?? 0).toDouble(); + totalNeeded += shift.workersNeeded ?? 0; + } + + // Aggregate fill rate from performance shifts (has 'filled' field) + int perfNeeded = 0; + int perfFilled = 0; + double totalFillTimeSeconds = 0; + int filledShiftsWithTime = 0; + + for (final dc.ListShiftsForPerformanceByBusinessShifts shift in perfShifts) { + perfNeeded += shift.workersNeeded ?? 0; + perfFilled += shift.filled ?? 0; + + if (shift.filledAt != null && shift.createdAt != null) { + final DateTime createdAt = shift.createdAt!.toDateTime(); + final DateTime filledAt = shift.filledAt!.toDateTime(); + totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; + filledShiftsWithTime++; + } + } + + // Aggregate total spend from invoices + double totalSpend = 0; + for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) { + totalSpend += (inv.amount ?? 0).toDouble(); + } + + // Fetch no-show rate using forecast shift IDs + final List shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList(); + double noShowRate = 0; + if (shiftIds.isNotEmpty) { + final QueryResult appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + final List apps = appsResponse.data.applications; + final List noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList(); + noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0; + } + + final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0; + + return ReportsSummary( + totalHours: totalHours, + otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it + totalSpend: totalSpend, + fillRate: fillRate, + avgFillTimeHours: filledShiftsWithTime == 0 + ? 0 + : (totalFillTimeSeconds / filledShiftsWithTime) / 3600, + noShowRate: noShowRate, + ); + }); + } +} + diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart new file mode 100644 index 00000000..14c44db9 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart @@ -0,0 +1,55 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for reports connector queries. +/// +/// This interface defines the contract for accessing report-related data +/// from the backend via Data Connect. +abstract interface class ReportsConnectorRepository { + /// Fetches the daily operations report for a specific business and date. + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }); + + /// Fetches the spend report for a specific business and date range. + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + /// Fetches the coverage report for a specific business and date range. + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + /// Fetches the forecast report for a specific business and date range. + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + /// Fetches the performance report for a specific business and date range. + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + /// Fetches the no-show report for a specific business and date range. + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + /// Fetches a summary of all reports for a specific business and date range. + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart new file mode 100644 index 00000000..cb760a6f --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart @@ -0,0 +1,797 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/shifts_connector_repository.dart'; + +/// Implementation of [ShiftsConnectorRepository]. +/// +/// Handles shift-related data operations by interacting with Data Connect. +class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository { + /// Creates a new [ShiftsConnectorRepositoryImpl]. + ShiftsConnectorRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future> getMyShifts({ + required String staffId, + required DateTime start, + required DateTime end, + }) async { + return _service.run(() async { + final dc.GetApplicationsByStaffIdVariablesBuilder query = _service + .connector + .getApplicationsByStaffId(staffId: staffId) + .dayStart(_service.toTimestamp(start)) + .dayEnd(_service.toTimestamp(end)); + + final QueryResult< + dc.GetApplicationsByStaffIdData, + dc.GetApplicationsByStaffIdVariables + > + response = await query.execute(); + return _mapApplicationsToShifts(response.data.applications); + }); + } + + @override + Future> getAvailableShifts({ + required String staffId, + String? query, + String? type, + }) async { + return _service.run(() async { + // First, fetch all available shift roles for the vendor/business + // Use the session owner ID (vendorId) + final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId; + if (vendorId == null || vendorId.isEmpty) return []; + + final QueryResult< + dc.ListShiftRolesByVendorIdData, + dc.ListShiftRolesByVendorIdVariables + > + response = await _service.connector + .listShiftRolesByVendorId(vendorId: vendorId) + .execute(); + + final List allShiftRoles = + response.data.shiftRoles; + + // Fetch current applications to filter out already booked shifts + final QueryResult< + dc.GetApplicationsByStaffIdData, + dc.GetApplicationsByStaffIdVariables + > + myAppsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + final Set appliedShiftIds = myAppsResponse.data.applications + .map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId) + .toSet(); + + final List mappedShifts = []; + for (final dc.ListShiftRolesByVendorIdShiftRoles sr in allShiftRoles) { + if (appliedShiftIds.contains(sr.shiftId)) continue; + + final DateTime? shiftDate = _service.toDateTime(sr.shift.date); + final DateTime? startDt = _service.toDateTime(sr.startTime); + final DateTime? endDt = _service.toDateTime(sr.endTime); + final DateTime? createdDt = _service.toDateTime(sr.createdAt); + + // Normalise orderType to uppercase for consistent checks in the UI. + // RECURRING โ†’ groups shifts into Multi-Day cards. + // PERMANENT โ†’ groups shifts into Long Term cards. + final String orderTypeStr = sr.shift.order.orderType.stringValue + .toUpperCase(); + + final dc.ListShiftRolesByVendorIdShiftRolesShiftOrder order = + sr.shift.order; + final DateTime? startDate = _service.toDateTime(order.startDate); + final DateTime? endDate = _service.toDateTime(order.endDate); + + final String startTime = startDt != null + ? DateFormat('HH:mm').format(startDt) + : ''; + final String endTime = endDt != null + ? DateFormat('HH:mm').format(endDt) + : ''; + + final List? schedules = _generateSchedules( + orderType: orderTypeStr, + startDate: startDate, + endDate: endDate, + recurringDays: order.recurringDays, + permanentDays: order.permanentDays, + startTime: startTime, + endTime: endTime, + ); + + mappedShifts.add( + Shift( + id: sr.shiftId, + roleId: sr.roleId, + title: sr.role.name, + clientName: sr.shift.order.business.businessName, + logoUrl: null, + hourlyRate: sr.role.costPerHour, + location: sr.shift.location ?? '', + locationAddress: sr.shift.locationAddress ?? '', + date: shiftDate?.toIso8601String() ?? '', + startTime: startTime, + endTime: endTime, + createdDate: createdDt?.toIso8601String() ?? '', + status: sr.shift.status?.stringValue.toLowerCase() ?? 'open', + description: sr.shift.description, + durationDays: sr.shift.durationDays ?? schedules?.length, + requiredSlots: sr.count, + filledSlots: sr.assigned ?? 0, + latitude: sr.shift.latitude, + longitude: sr.shift.longitude, + // orderId + orderType power the grouping and type-badge logic in + // FindShiftsTab._groupMultiDayShifts and MyShiftCard._getShiftType. + orderId: sr.shift.orderId, + orderType: orderTypeStr, + startDate: startDate?.toIso8601String(), + endDate: endDate?.toIso8601String(), + recurringDays: sr.shift.order.recurringDays, + permanentDays: sr.shift.order.permanentDays, + schedules: schedules, + breakInfo: BreakAdapter.fromData( + isPaid: sr.isBreakPaid ?? false, + breakTime: sr.breakType?.stringValue, + ), + ), + ); + } + + if (query != null && query.isNotEmpty) { + final String lowerQuery = query.toLowerCase(); + return mappedShifts.where((Shift s) { + return s.title.toLowerCase().contains(lowerQuery) || + s.clientName.toLowerCase().contains(lowerQuery); + }).toList(); + } + + return mappedShifts; + }); + } + + @override + Future> getPendingAssignments({required String staffId}) async { + return _service.run(() async { + // Current schema doesn't have a specific "pending assignment" query that differs from confirmed + // unless we filter by status. In the old repo it was returning an empty list. + return []; + }); + } + + @override + Future getShiftDetails({ + required String shiftId, + required String staffId, + String? roleId, + }) async { + return _service.run(() async { + if (roleId != null && roleId.isNotEmpty) { + final QueryResult + roleResult = await _service.connector + .getShiftRoleById(shiftId: shiftId, roleId: roleId) + .execute(); + final dc.GetShiftRoleByIdShiftRole? sr = roleResult.data.shiftRole; + if (sr == null) return null; + + final DateTime? startDt = _service.toDateTime(sr.startTime); + final DateTime? endDt = _service.toDateTime(sr.endTime); + final DateTime? createdDt = _service.toDateTime(sr.createdAt); + + bool hasApplied = false; + String status = 'open'; + + final QueryResult< + dc.GetApplicationsByStaffIdData, + dc.GetApplicationsByStaffIdVariables + > + appsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + + final dc.GetApplicationsByStaffIdApplications? app = appsResponse + .data + .applications + .where( + (dc.GetApplicationsByStaffIdApplications a) => + a.shiftId == shiftId && a.shiftRole.roleId == roleId, + ) + .firstOrNull; + + if (app != null) { + hasApplied = true; + final String s = app.status.stringValue; + status = _mapApplicationStatus(s); + } + + return Shift( + id: sr.shiftId, + roleId: sr.roleId, + title: sr.shift.order.business.businessName, + clientName: sr.shift.order.business.businessName, + logoUrl: sr.shift.order.business.companyLogoUrl, + hourlyRate: sr.role.costPerHour, + location: sr.shift.location ?? sr.shift.order.teamHub.hubName, + locationAddress: sr.shift.locationAddress ?? '', + date: startDt?.toIso8601String() ?? '', + startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', + endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', + createdDate: createdDt?.toIso8601String() ?? '', + status: status, + description: sr.shift.description, + durationDays: null, + requiredSlots: sr.count, + filledSlots: sr.assigned ?? 0, + hasApplied: hasApplied, + totalValue: sr.totalValue, + latitude: sr.shift.latitude, + longitude: sr.shift.longitude, + breakInfo: BreakAdapter.fromData( + isPaid: sr.isBreakPaid ?? false, + breakTime: sr.breakType?.stringValue, + ), + ); + } + + final QueryResult result = + await _service.connector.getShiftById(id: shiftId).execute(); + final dc.GetShiftByIdShift? s = result.data.shift; + if (s == null) return null; + + int? required; + int? filled; + Break? breakInfo; + + try { + final QueryResult< + dc.ListShiftRolesByShiftIdData, + dc.ListShiftRolesByShiftIdVariables + > + rolesRes = await _service.connector + .listShiftRolesByShiftId(shiftId: shiftId) + .execute(); + if (rolesRes.data.shiftRoles.isNotEmpty) { + required = 0; + filled = 0; + for (dc.ListShiftRolesByShiftIdShiftRoles r + in rolesRes.data.shiftRoles) { + required = (required ?? 0) + r.count; + filled = (filled ?? 0) + (r.assigned ?? 0); + } + final dc.ListShiftRolesByShiftIdShiftRoles firstRole = + rolesRes.data.shiftRoles.first; + breakInfo = BreakAdapter.fromData( + isPaid: firstRole.isBreakPaid ?? false, + breakTime: firstRole.breakType?.stringValue, + ); + } + } catch (_) {} + + final DateTime? startDt = _service.toDateTime(s.startTime); + final DateTime? endDt = _service.toDateTime(s.endTime); + final DateTime? createdDt = _service.toDateTime(s.createdAt); + + return Shift( + id: s.id, + title: s.title, + clientName: s.order.business.businessName, + logoUrl: null, + hourlyRate: s.cost ?? 0.0, + location: s.location ?? '', + locationAddress: s.locationAddress ?? '', + date: startDt?.toIso8601String() ?? '', + startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', + endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', + createdDate: createdDt?.toIso8601String() ?? '', + status: s.status?.stringValue ?? 'OPEN', + description: s.description, + durationDays: s.durationDays, + requiredSlots: required, + filledSlots: filled, + latitude: s.latitude, + longitude: s.longitude, + breakInfo: breakInfo, + ); + }); + } + + @override + Future applyForShift({ + required String shiftId, + required String staffId, + bool isInstantBook = false, + String? roleId, + }) async { + return _service.run(() async { + final String targetRoleId = roleId ?? ''; + if (targetRoleId.isEmpty) throw Exception('Missing role id.'); + + // 1. Fetch the initial shift to determine order type + final QueryResult + shiftResult = await _service.connector + .getShiftById(id: shiftId) + .execute(); + final dc.GetShiftByIdShift? initialShift = shiftResult.data.shift; + if (initialShift == null) throw Exception('Shift not found'); + + final dc.EnumValue orderTypeEnum = + initialShift.order.orderType; + final bool isMultiDay = + orderTypeEnum is dc.Known && + (orderTypeEnum.value == dc.OrderType.RECURRING || + orderTypeEnum.value == dc.OrderType.PERMANENT); + final List<_TargetShiftRole> targets = []; + + if (isMultiDay) { + // 2. Fetch all shifts for this order to apply to all of them for the same role + final QueryResult< + dc.ListShiftRolesByBusinessAndOrderData, + dc.ListShiftRolesByBusinessAndOrderVariables + > + allRolesRes = await _service.connector + .listShiftRolesByBusinessAndOrder( + businessId: initialShift.order.businessId, + orderId: initialShift.orderId, + ) + .execute(); + + for (final role in allRolesRes.data.shiftRoles) { + if (role.roleId == targetRoleId) { + targets.add( + _TargetShiftRole( + shiftId: role.shiftId, + roleId: role.roleId, + count: role.count, + assigned: role.assigned ?? 0, + shiftFilled: role.shift.filled ?? 0, + date: _service.toDateTime(role.shift.date), + ), + ); + } + } + } else { + // Single shift application + final QueryResult + roleResult = await _service.connector + .getShiftRoleById(shiftId: shiftId, roleId: targetRoleId) + .execute(); + final dc.GetShiftRoleByIdShiftRole? role = roleResult.data.shiftRole; + if (role == null) throw Exception('Shift role not found'); + + targets.add( + _TargetShiftRole( + shiftId: shiftId, + roleId: targetRoleId, + count: role.count, + assigned: role.assigned ?? 0, + shiftFilled: initialShift.filled ?? 0, + date: _service.toDateTime(initialShift.date), + ), + ); + } + + if (targets.isEmpty) { + throw Exception('No valid shifts found to apply for.'); + } + + int appliedCount = 0; + final List errors = []; + + for (final target in targets) { + try { + await _applyToSingleShiftRole(target: target, staffId: staffId); + appliedCount++; + } catch (e) { + // For multi-shift apply, we might want to continue even if some fail due to conflicts + if (targets.length == 1) rethrow; + errors.add('Shift on ${target.date}: ${e.toString()}'); + } + } + + if (appliedCount == 0 && targets.length > 1) { + throw Exception('Failed to apply for any shifts: ${errors.join(", ")}'); + } + }); + } + + Future _applyToSingleShiftRole({ + required _TargetShiftRole target, + required String staffId, + }) async { + // Validate daily limit + if (target.date != null) { + final DateTime dayStartUtc = DateTime.utc( + target.date!.year, + target.date!.month, + target.date!.day, + ); + final DateTime dayEndUtc = dayStartUtc + .add(const Duration(days: 1)) + .subtract(const Duration(microseconds: 1)); + + final QueryResult< + dc.VaidateDayStaffApplicationData, + dc.VaidateDayStaffApplicationVariables + > + validationResponse = await _service.connector + .vaidateDayStaffApplication(staffId: staffId) + .dayStart(_service.toTimestamp(dayStartUtc)) + .dayEnd(_service.toTimestamp(dayEndUtc)) + .execute(); + + if (validationResponse.data.applications.isNotEmpty) { + throw Exception('The user already has a shift that day.'); + } + } + + // Check for existing application + final QueryResult< + dc.GetApplicationByStaffShiftAndRoleData, + dc.GetApplicationByStaffShiftAndRoleVariables + > + existingAppRes = await _service.connector + .getApplicationByStaffShiftAndRole( + staffId: staffId, + shiftId: target.shiftId, + roleId: target.roleId, + ) + .execute(); + + if (existingAppRes.data.applications.isNotEmpty) { + throw Exception('Application already exists.'); + } + + if (target.assigned >= target.count) { + throw Exception('This shift is full.'); + } + + String? createdAppId; + try { + final OperationResult< + dc.CreateApplicationData, + dc.CreateApplicationVariables + > + createRes = await _service.connector + .createApplication( + shiftId: target.shiftId, + staffId: staffId, + roleId: target.roleId, + status: dc.ApplicationStatus.CONFIRMED, + origin: dc.ApplicationOrigin.STAFF, + ) + .execute(); + + createdAppId = createRes.data.application_insert.id; + + await _service.connector + .updateShiftRole(shiftId: target.shiftId, roleId: target.roleId) + .assigned(target.assigned + 1) + .execute(); + + await _service.connector + .updateShift(id: target.shiftId) + .filled(target.shiftFilled + 1) + .execute(); + } catch (e) { + // Simple rollback attempt (not guaranteed) + if (createdAppId != null) { + await _service.connector.deleteApplication(id: createdAppId).execute(); + } + rethrow; + } + } + + @override + Future acceptShift({required String shiftId, required String staffId}) { + return _updateApplicationStatus( + shiftId, + staffId, + dc.ApplicationStatus.CONFIRMED, + ); + } + + @override + Future declineShift({ + required String shiftId, + required String staffId, + }) { + return _updateApplicationStatus( + shiftId, + staffId, + dc.ApplicationStatus.REJECTED, + ); + } + + @override + Future> getCancelledShifts({required String staffId}) async { + return _service.run(() async { + // Logic would go here to fetch by REJECTED status if needed + return []; + }); + } + + @override + Future> getHistoryShifts({required String staffId}) async { + return _service.run(() async { + final QueryResult< + dc.ListCompletedApplicationsByStaffIdData, + dc.ListCompletedApplicationsByStaffIdVariables + > + response = await _service.connector + .listCompletedApplicationsByStaffId(staffId: staffId) + .execute(); + + final List shifts = []; + for (final dc.ListCompletedApplicationsByStaffIdApplications app + in response.data.applications) { + final String roleName = app.shiftRole.role.name; + final String orderName = + (app.shift.order.eventName ?? '').trim().isNotEmpty + ? app.shift.order.eventName! + : app.shift.order.business.businessName; + final String title = '$roleName - $orderName'; + + final DateTime? shiftDate = _service.toDateTime(app.shift.date); + final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); + final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); + final DateTime? createdDt = _service.toDateTime(app.createdAt); + + shifts.add( + Shift( + id: app.shift.id, + roleId: app.shiftRole.roleId, + title: title, + clientName: app.shift.order.business.businessName, + logoUrl: app.shift.order.business.companyLogoUrl, + hourlyRate: app.shiftRole.role.costPerHour, + location: app.shift.location ?? '', + locationAddress: app.shift.order.teamHub.hubName, + date: shiftDate?.toIso8601String() ?? '', + startTime: startDt != null + ? DateFormat('HH:mm').format(startDt) + : '', + endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', + createdDate: createdDt?.toIso8601String() ?? '', + status: 'completed', // Hardcoded as checked out implies completion + description: app.shift.description, + durationDays: app.shift.durationDays, + requiredSlots: app.shiftRole.count, + filledSlots: app.shiftRole.assigned ?? 0, + hasApplied: true, + latitude: app.shift.latitude, + longitude: app.shift.longitude, + breakInfo: BreakAdapter.fromData( + isPaid: app.shiftRole.isBreakPaid ?? false, + breakTime: app.shiftRole.breakType?.stringValue, + ), + ), + ); + } + return shifts; + }); + } + + // --- PRIVATE HELPERS --- + + List _mapApplicationsToShifts(List apps) { + return apps.map((app) { + final String roleName = app.shiftRole.role.name; + final String orderName = + (app.shift.order.eventName ?? '').trim().isNotEmpty + ? app.shift.order.eventName! + : app.shift.order.business.businessName; + final String title = '$roleName - $orderName'; + + final DateTime? shiftDate = _service.toDateTime(app.shift.date); + final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); + final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); + final DateTime? createdDt = _service.toDateTime(app.createdAt); + + final bool hasCheckIn = app.checkInTime != null; + final bool hasCheckOut = app.checkOutTime != null; + + String status; + if (hasCheckOut) { + status = 'completed'; + } else if (hasCheckIn) { + status = 'checked_in'; + } else { + status = _mapApplicationStatus(app.status.stringValue); + } + + return Shift( + id: app.shift.id, + roleId: app.shiftRole.roleId, + title: title, + clientName: app.shift.order.business.businessName, + logoUrl: app.shift.order.business.companyLogoUrl, + hourlyRate: app.shiftRole.role.costPerHour, + location: app.shift.location ?? '', + locationAddress: app.shift.order.teamHub.hubName, + date: shiftDate?.toIso8601String() ?? '', + startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', + endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', + createdDate: createdDt?.toIso8601String() ?? '', + status: status, + description: app.shift.description, + durationDays: app.shift.durationDays, + requiredSlots: app.shiftRole.count, + filledSlots: app.shiftRole.assigned ?? 0, + hasApplied: true, + latitude: app.shift.latitude, + longitude: app.shift.longitude, + breakInfo: BreakAdapter.fromData( + isPaid: app.shiftRole.isBreakPaid ?? false, + breakTime: app.shiftRole.breakType?.stringValue, + ), + ); + }).toList(); + } + + String _mapApplicationStatus(String status) { + switch (status) { + case 'CONFIRMED': + return 'confirmed'; + case 'PENDING': + return 'pending'; + case 'CHECKED_OUT': + return 'completed'; + case 'REJECTED': + return 'cancelled'; + default: + return 'open'; + } + } + + Future _updateApplicationStatus( + String shiftId, + String staffId, + dc.ApplicationStatus newStatus, + ) async { + return _service.run(() async { + // First try to find the application + final QueryResult< + dc.GetApplicationsByStaffIdData, + dc.GetApplicationsByStaffIdVariables + > + appsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + + final dc.GetApplicationsByStaffIdApplications? app = appsResponse + .data + .applications + .where( + (dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId, + ) + .firstOrNull; + + if (app != null) { + await _service.connector + .updateApplicationStatus(id: app.id) + .status(newStatus) + .execute(); + } else if (newStatus == dc.ApplicationStatus.REJECTED) { + // If declining but no app found, create a rejected application + final QueryResult< + dc.ListShiftRolesByShiftIdData, + dc.ListShiftRolesByShiftIdVariables + > + rolesRes = await _service.connector + .listShiftRolesByShiftId(shiftId: shiftId) + .execute(); + + if (rolesRes.data.shiftRoles.isNotEmpty) { + final dc.ListShiftRolesByShiftIdShiftRoles firstRole = + rolesRes.data.shiftRoles.first; + await _service.connector + .createApplication( + shiftId: shiftId, + staffId: staffId, + roleId: firstRole.id, + status: dc.ApplicationStatus.REJECTED, + origin: dc.ApplicationOrigin.STAFF, + ) + .execute(); + } + } else { + throw Exception("Application not found for shift $shiftId"); + } + }); + } + + /// Generates a list of [ShiftSchedule] for RECURRING or PERMANENT orders. + List? _generateSchedules({ + required String orderType, + required DateTime? startDate, + required DateTime? endDate, + required List? recurringDays, + required List? permanentDays, + required String startTime, + required String endTime, + }) { + if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null; + if (startDate == null || endDate == null) return null; + + final List? daysToInclude = orderType == 'RECURRING' + ? recurringDays + : permanentDays; + if (daysToInclude == null || daysToInclude.isEmpty) return null; + + final List schedules = []; + final Set targetWeekdayIndex = daysToInclude + .map((String day) { + switch (day.toUpperCase()) { + case 'MONDAY': + return DateTime.monday; + case 'TUESDAY': + return DateTime.tuesday; + case 'WEDNESDAY': + return DateTime.wednesday; + case 'THURSDAY': + return DateTime.thursday; + case 'FRIDAY': + return DateTime.friday; + case 'SATURDAY': + return DateTime.saturday; + case 'SUNDAY': + return DateTime.sunday; + default: + return -1; + } + }) + .where((int idx) => idx != -1) + .toSet(); + + DateTime current = startDate; + while (current.isBefore(endDate) || + current.isAtSameMomentAs(endDate) || + // Handle cases where the time component might differ slightly by checking date equality + (current.year == endDate.year && + current.month == endDate.month && + current.day == endDate.day)) { + if (targetWeekdayIndex.contains(current.weekday)) { + schedules.add( + ShiftSchedule( + date: current.toIso8601String(), + startTime: startTime, + endTime: endTime, + ), + ); + } + current = current.add(const Duration(days: 1)); + + // Safety break to prevent infinite loops if dates are messed up + if (schedules.length > 365) break; + } + + return schedules; + } +} + +class _TargetShiftRole { + final String shiftId; + final String roleId; + final int count; + final int assigned; + final int shiftFilled; + final DateTime? date; + + _TargetShiftRole({ + required this.shiftId, + required this.roleId, + required this.count, + required this.assigned, + required this.shiftFilled, + this.date, + }); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart new file mode 100644 index 00000000..bb8b50af --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart @@ -0,0 +1,56 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for shifts connector operations. +/// +/// This acts as a buffer layer between the domain repository and the Data Connect SDK. +abstract interface class ShiftsConnectorRepository { + /// Retrieves shifts assigned to the current staff member. + Future> getMyShifts({ + required String staffId, + required DateTime start, + required DateTime end, + }); + + /// Retrieves available shifts. + Future> getAvailableShifts({ + required String staffId, + String? query, + String? type, + }); + + /// Retrieves pending shift assignments for the current staff member. + Future> getPendingAssignments({required String staffId}); + + /// Retrieves detailed information for a specific shift. + Future getShiftDetails({ + required String shiftId, + required String staffId, + String? roleId, + }); + + /// Applies for a specific open shift. + Future applyForShift({ + required String shiftId, + required String staffId, + bool isInstantBook = false, + String? roleId, + }); + + /// Accepts a pending shift assignment. + Future acceptShift({ + required String shiftId, + required String staffId, + }); + + /// Declines a pending shift assignment. + Future declineShift({ + required String shiftId, + required String staffId, + }); + + /// Retrieves cancelled shifts for the current staff member. + Future> getCancelledShifts({required String staffId}); + + /// Retrieves historical (completed) shifts for the current staff member. + Future> getHistoryShifts({required String staffId}); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart new file mode 100644 index 00000000..24f01a00 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -0,0 +1,352 @@ +๏ปฟimport 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/repositories/staff_connector_repository.dart'; + +/// Implementation of [StaffConnectorRepository]. +/// +/// Fetches staff-related data from the Data Connect backend using +/// the staff connector queries. +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + /// Creates a new [StaffConnectorRepositoryImpl]. + /// + /// Requires a [DataConnectService] instance for backend communication. + StaffConnectorRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.GetStaffProfileCompletionData, + dc.GetStaffProfileCompletionVariables + > + response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + final dc.GetStaffProfileCompletionStaff? staff = response.data.staff; + final List + emergencyContacts = response.data.emergencyContacts; + final List taxForms = + response.data.taxForms; + + return _isProfileComplete(staff, emergencyContacts, taxForms); + }); + } + + @override + Future getPersonalInfoCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.GetStaffPersonalInfoCompletionData, + dc.GetStaffPersonalInfoCompletionVariables + > + response = await _service.connector + .getStaffPersonalInfoCompletion(id: staffId) + .execute(); + + final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; + return _isPersonalInfoComplete(staff); + }); + } + + @override + Future getEmergencyContactsCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.GetStaffEmergencyProfileCompletionData, + dc.GetStaffEmergencyProfileCompletionVariables + > + response = await _service.connector + .getStaffEmergencyProfileCompletion(id: staffId) + .execute(); + + return response.data.emergencyContacts.isNotEmpty; + }); + } + + @override + Future getExperienceCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.GetStaffExperienceProfileCompletionData, + dc.GetStaffExperienceProfileCompletionVariables + > + response = await _service.connector + .getStaffExperienceProfileCompletion(id: staffId) + .execute(); + + final dc.GetStaffExperienceProfileCompletionStaff? staff = + response.data.staff; + return _hasExperience(staff); + }); + } + + @override + Future getTaxFormsCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.GetStaffTaxFormsProfileCompletionData, + dc.GetStaffTaxFormsProfileCompletionVariables + > + response = await _service.connector + .getStaffTaxFormsProfileCompletion(id: staffId) + .execute(); + + return response.data.taxForms.isNotEmpty; + }); + } + + /// Checks if personal info is complete. + bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) { + if (staff == null) return false; + final String fullName = staff.fullName; + final String? email = staff.email; + final String? phone = staff.phone; + return fullName.trim().isNotEmpty && + (email?.trim().isNotEmpty ?? false) && + (phone?.trim().isNotEmpty ?? false); + } + + /// Checks if staff has experience data (skills or industries). + bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) { + if (staff == null) return false; + final List? skills = staff.skills; + final List? industries = staff.industries; + return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); + } + + /// Determines if the profile is complete based on all sections. + bool _isProfileComplete( + dc.GetStaffProfileCompletionStaff? staff, + List emergencyContacts, + List taxForms, + ) { + if (staff == null) return false; + + final List? skills = staff.skills; + final List? industries = staff.industries; + final bool hasExperience = + (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); + + return (staff.fullName.trim().isNotEmpty) && + (staff.email?.trim().isNotEmpty ?? false) && + emergencyContacts.isNotEmpty && + taxForms.isNotEmpty && + hasExperience; + } + + @override + Future getStaffProfile() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult + response = await _service.connector.getStaffById(id: staffId).execute(); + + final dc.GetStaffByIdStaff? staff = response.data.staff; + + if (staff == null) { + throw Exception('Staff not found'); + } + + return domain.Staff( + id: staff.id, + authProviderId: staff.userId, + name: staff.fullName, + email: staff.email ?? '', + phone: staff.phone, + avatar: staff.photoUrl, + status: domain.StaffStatus.active, + address: staff.addres, + totalShifts: staff.totalShifts, + averageRating: staff.averageRating, + onTimeRate: staff.onTimeRate, + noShowCount: staff.noShowCount, + cancellationCount: staff.cancellationCount, + reliabilityScore: staff.reliabilityScore, + ); + }); + } + + @override + Future> getBenefits() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult< + dc.ListBenefitsDataByStaffIdData, + dc.ListBenefitsDataByStaffIdVariables + > + response = await _service.connector + .listBenefitsDataByStaffId(staffId: staffId) + .execute(); + + return response.data.benefitsDatas + .map( + (dc.ListBenefitsDataByStaffIdBenefitsDatas e) => domain.Benefit( + title: e.vendorBenefitPlan.title, + entitlementHours: e.vendorBenefitPlan.total?.toDouble() ?? 0, + usedHours: e.current.toDouble(), + ), + ) + .toList(); + }); + } + + @override + Future> getAttireOptions() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final List> results = + await Future.wait>( + >>[ + _service.connector.listAttireOptions().execute(), + _service.connector.getStaffAttire(staffId: staffId).execute(), + ], + ); + + final QueryResult optionsRes = + results[0] as QueryResult; + final QueryResult + staffAttireRes = + results[1] + as QueryResult; + + final List staffAttire = + staffAttireRes.data.staffAttires; + + return optionsRes.data.attireOptions.map(( + dc.ListAttireOptionsAttireOptions opt, + ) { + final dc.GetStaffAttireStaffAttires currentAttire = staffAttire + .firstWhere( + (dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id, + orElse: () => dc.GetStaffAttireStaffAttires( + attireOptionId: opt.id, + verificationPhotoUrl: null, + verificationId: null, + verificationStatus: null, + ), + ); + + return domain.AttireItem( + id: opt.id, + code: opt.itemId, + label: opt.label, + description: opt.description, + imageUrl: opt.imageUrl, + isMandatory: opt.isMandatory ?? false, + photoUrl: currentAttire.verificationPhotoUrl, + verificationId: currentAttire.verificationId, + verificationStatus: currentAttire.verificationStatus != null + ? _mapFromDCStatus(currentAttire.verificationStatus!) + : null, + ); + }).toList(); + }); + } + + @override + Future upsertStaffAttire({ + required String attireOptionId, + required String photoUrl, + String? verificationId, + domain.AttireVerificationStatus? verificationStatus, + }) async { + await _service.run(() async { + final String staffId = await _service.getStaffId(); + + await _service.connector + .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) + .verificationPhotoUrl(photoUrl) + .verificationId(verificationId) + .verificationStatus( + verificationStatus != null + ? dc.AttireVerificationStatus.values.firstWhere( + (dc.AttireVerificationStatus e) => + e.name == verificationStatus.value.toUpperCase(), + orElse: () => dc.AttireVerificationStatus.PENDING, + ) + : null, + ) + .execute(); + }); + } + + domain.AttireVerificationStatus _mapFromDCStatus( + dc.EnumValue status, + ) { + if (status is dc.Unknown) { + return domain.AttireVerificationStatus.error; + } + final String name = + (status as dc.Known).value.name; + switch (name) { + case 'PENDING': + return domain.AttireVerificationStatus.pending; + case 'PROCESSING': + return domain.AttireVerificationStatus.processing; + case 'AUTO_PASS': + return domain.AttireVerificationStatus.autoPass; + case 'AUTO_FAIL': + return domain.AttireVerificationStatus.autoFail; + case 'NEEDS_REVIEW': + return domain.AttireVerificationStatus.needsReview; + case 'APPROVED': + return domain.AttireVerificationStatus.approved; + case 'REJECTED': + return domain.AttireVerificationStatus.rejected; + case 'ERROR': + return domain.AttireVerificationStatus.error; + default: + return domain.AttireVerificationStatus.error; + } + } + + @override + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }) async { + await _service.run(() async { + final String staffId = await _service.getStaffId(); + final String? fullName = (firstName != null || lastName != null) + ? '${firstName ?? ''} ${lastName ?? ''}'.trim() + : null; + + await _service.connector + .updateStaff(id: staffId) + .fullName(fullName) + .bio(bio) + .photoUrl(profilePictureUrl) + .execute(); + }); + } + + @override + Future signOut() async { + try { + await _service.auth.signOut(); + _service.clearCache(); + } catch (e) { + throw Exception('Error signing out: ${e.toString()}'); + } + } +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart new file mode 100644 index 00000000..3bd3c9e7 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -0,0 +1,75 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for staff connector queries. +/// +/// This interface defines the contract for accessing staff-related data +/// from the backend via Data Connect. +abstract interface class StaffConnectorRepository { + /// Fetches whether the profile is complete for the current staff member. + /// + /// Returns true if all required profile sections have been completed, + /// false otherwise. + /// + /// Throws an exception if the query fails. + Future getProfileCompletion(); + + /// Fetches personal information completion status. + /// + /// Returns true if personal info (name, email, phone, locations) is complete. + Future getPersonalInfoCompletion(); + + /// Fetches emergency contacts completion status. + /// + /// Returns true if at least one emergency contact exists. + Future getEmergencyContactsCompletion(); + + /// Fetches experience completion status. + /// + /// Returns true if staff has industries or skills defined. + Future getExperienceCompletion(); + + /// Fetches tax forms completion status. + /// + /// Returns true if at least one tax form exists. + Future getTaxFormsCompletion(); + + /// Fetches the full staff profile for the current authenticated user. + /// + /// Returns a [Staff] entity containing all profile information. + /// + /// Throws an exception if the profile cannot be retrieved. + Future getStaffProfile(); + + /// Fetches the benefits for the current authenticated user. + /// + /// Returns a list of [Benefit] entities. + Future> getBenefits(); + + /// Fetches the attire options for the current authenticated user. + /// + /// Returns a list of [AttireItem] entities. + Future> getAttireOptions(); + + /// Upserts staff attire photo information. + Future upsertStaffAttire({ + required String attireOptionId, + required String photoUrl, + String? verificationId, + AttireVerificationStatus? verificationStatus, + }); + + /// Signs out the current user. + /// + /// Clears the user's session and authentication state. + /// + /// Throws an exception if the sign-out fails. + Future signOut(); + + /// Saves the staff profile information. + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart new file mode 100644 index 00000000..63c43dd4 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving emergency contacts completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has at least one emergency contact registered. +/// It delegates to the repository for data access. +class GetEmergencyContactsCompletionUseCase extends NoInputUseCase { + /// Creates a [GetEmergencyContactsCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetEmergencyContactsCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get emergency contacts completion status. + /// + /// Returns true if emergency contacts are registered, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getEmergencyContactsCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart new file mode 100644 index 00000000..e744add4 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving experience completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has experience data (skills or industries) defined. +/// It delegates to the repository for data access. +class GetExperienceCompletionUseCase extends NoInputUseCase { + /// Creates a [GetExperienceCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetExperienceCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get experience completion status. + /// + /// Returns true if experience data is defined, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getExperienceCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart new file mode 100644 index 00000000..a4a3f46d --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving personal information completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member's personal information is complete (name, email, phone). +/// It delegates to the repository for data access. +class GetPersonalInfoCompletionUseCase extends NoInputUseCase { + /// Creates a [GetPersonalInfoCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetPersonalInfoCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get personal info completion status. + /// + /// Returns true if personal information is complete, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getPersonalInfoCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart new file mode 100644 index 00000000..f079eb23 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving staff profile completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member's profile is complete. It delegates to the repository +/// for data access. +class GetProfileCompletionUseCase extends NoInputUseCase { + /// Creates a [GetProfileCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetProfileCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get profile completion status. + /// + /// Returns true if the profile is complete, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getProfileCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart new file mode 100644 index 00000000..3889bd49 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart @@ -0,0 +1,28 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for fetching a staff member's full profile information. +/// +/// This use case encapsulates the business logic for retrieving the complete +/// staff profile including personal info, ratings, and reliability scores. +/// It delegates to the repository for data access. +class GetStaffProfileUseCase extends UseCase { + /// Creates a [GetStaffProfileUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetStaffProfileUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get the staff profile. + /// + /// Returns a [Staff] entity containing all profile information. + /// + /// Throws an exception if the operation fails. + @override + Future call([void params]) => _repository.getStaffProfile(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart new file mode 100644 index 00000000..9a8fda29 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving tax forms completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has at least one tax form submitted. +/// It delegates to the repository for data access. +class GetTaxFormsCompletionUseCase extends NoInputUseCase { + /// Creates a [GetTaxFormsCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetTaxFormsCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get tax forms completion status. + /// + /// Returns true if tax forms are submitted, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getTaxFormsCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart new file mode 100644 index 00000000..4331006c --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for signing out the current staff user. +/// +/// This use case encapsulates the business logic for signing out, +/// including clearing authentication state and cache. +/// It delegates to the repository for data access. +class SignOutStaffUseCase extends NoInputUseCase { + /// Creates a [SignOutStaffUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + SignOutStaffUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to sign out the user. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.signOut(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart index 5704afb6..53b9428f 100644 --- a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart +++ b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart @@ -1,4 +1,14 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'connectors/reports/domain/repositories/reports_connector_repository.dart'; +import 'connectors/reports/data/repositories/reports_connector_repository_impl.dart'; +import 'connectors/shifts/domain/repositories/shifts_connector_repository.dart'; +import 'connectors/shifts/data/repositories/shifts_connector_repository_impl.dart'; +import 'connectors/hubs/domain/repositories/hubs_connector_repository.dart'; +import 'connectors/hubs/data/repositories/hubs_connector_repository_impl.dart'; +import 'connectors/billing/domain/repositories/billing_connector_repository.dart'; +import 'connectors/billing/data/repositories/billing_connector_repository_impl.dart'; +import 'connectors/coverage/domain/repositories/coverage_connector_repository.dart'; +import 'connectors/coverage/data/repositories/coverage_connector_repository_impl.dart'; import 'services/data_connect_service.dart'; /// A module that provides Data Connect dependencies. @@ -6,5 +16,22 @@ class DataConnectModule extends Module { @override void exportedBinds(Injector i) { i.addInstance(DataConnectService.instance); + + // Repositories + i.addLazySingleton( + ReportsConnectorRepositoryImpl.new, + ); + i.addLazySingleton( + ShiftsConnectorRepositoryImpl.new, + ); + i.addLazySingleton( + HubsConnectorRepositoryImpl.new, + ); + i.addLazySingleton( + BillingConnectorRepositoryImpl.new, + ); + i.addLazySingleton( + CoverageConnectorRepositoryImpl.new, + ); } } diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index 19799467..8e79de80 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -1,12 +1,22 @@ -import 'dart:async'; - -import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:flutter/material.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import 'package:flutter/foundation.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; -import '../../krow_data_connect.dart' as dc; +import '../connectors/reports/domain/repositories/reports_connector_repository.dart'; +import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart'; +import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart'; +import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart'; +import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart'; +import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart'; +import '../connectors/billing/domain/repositories/billing_connector_repository.dart'; +import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart'; +import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart'; +import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart'; +import '../connectors/staff/domain/repositories/staff_connector_repository.dart'; +import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart'; import 'mixins/data_error_handler.dart'; import 'mixins/session_handler_mixin.dart'; @@ -22,176 +32,208 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { /// The Data Connect connector used for data operations. final dc.ExampleConnector connector = dc.ExampleConnector.instance; - /// The Firebase Auth instance. - firebase_auth.FirebaseAuth get auth => _auth; - final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; + // Repositories + ReportsConnectorRepository? _reportsRepository; + ShiftsConnectorRepository? _shiftsRepository; + HubsConnectorRepository? _hubsRepository; + BillingConnectorRepository? _billingRepository; + CoverageConnectorRepository? _coverageRepository; + StaffConnectorRepository? _staffRepository; - /// Cache for the current staff ID to avoid redundant lookups. - String? _cachedStaffId; + /// Gets the reports connector repository. + ReportsConnectorRepository getReportsRepository() { + return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this); + } - /// Cache for the current business ID to avoid redundant lookups. - String? _cachedBusinessId; + /// Gets the shifts connector repository. + ShiftsConnectorRepository getShiftsRepository() { + return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this); + } - /// Gets the current staff ID from session store or persistent storage. + /// Gets the hubs connector repository. + HubsConnectorRepository getHubsRepository() { + return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this); + } + + /// Gets the billing connector repository. + BillingConnectorRepository getBillingRepository() { + return _billingRepository ??= BillingConnectorRepositoryImpl(service: this); + } + + /// Gets the coverage connector repository. + CoverageConnectorRepository getCoverageRepository() { + return _coverageRepository ??= CoverageConnectorRepositoryImpl( + service: this, + ); + } + + /// Gets the staff connector repository. + StaffConnectorRepository getStaffRepository() { + return _staffRepository ??= StaffConnectorRepositoryImpl(service: this); + } + + /// Returns the current Firebase Auth instance. + @override + firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance; + + /// Helper to get the current staff ID from the session. Future getStaffId() async { - // 1. Check Session Store - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - if (session?.staff?.id != null) { - return session!.staff!.id; - } + String? staffId = dc.StaffSessionStore.instance.session?.staff?.id; - // 2. Check Cache - if (_cachedStaffId != null) return _cachedStaffId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User is not authenticated', - ); - } - - try { - final fdc.QueryResult< - dc.GetStaffByUserIdData, - dc.GetStaffByUserIdVariables - > - response = await executeProtected( - () => connector.getStaffByUserId(userId: user.uid).execute(), - ); - - if (response.data.staffs.isNotEmpty) { - _cachedStaffId = response.data.staffs.first.id; - return _cachedStaffId!; + if (staffId == null || staffId.isEmpty) { + // Attempt to recover session if user is signed in + final user = auth.currentUser; + if (user != null) { + await _loadSession(user.uid); + staffId = dc.StaffSessionStore.instance.session?.staff?.id; } - } catch (e) { - throw Exception('Failed to fetch staff ID from Data Connect: $e'); } - // 4. Fallback (should ideally not happen if DB is seeded) - return user.uid; + if (staffId == null || staffId.isEmpty) { + throw Exception('No staff ID found in session.'); + } + return staffId; } - /// Gets the current business ID from session store or persistent storage. + /// Helper to get the current business ID from the session. Future getBusinessId() async { - // 1. Check Session Store - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - if (session?.business?.id != null) { - return session!.business!.id; - } + String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - // 2. Check Cache - if (_cachedBusinessId != null) return _cachedBusinessId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User is not authenticated', - ); - } - - try { - final fdc.QueryResult< - dc.GetBusinessesByUserIdData, - dc.GetBusinessesByUserIdVariables - > - response = await executeProtected( - () => connector.getBusinessesByUserId(userId: user.uid).execute(), - ); - - if (response.data.businesses.isNotEmpty) { - _cachedBusinessId = response.data.businesses.first.id; - return _cachedBusinessId!; + if (businessId == null || businessId.isEmpty) { + // Attempt to recover session if user is signed in + final user = auth.currentUser; + if (user != null) { + await _loadSession(user.uid); + businessId = dc.ClientSessionStore.instance.session?.business?.id; } - } catch (e) { - throw Exception('Failed to fetch business ID from Data Connect: $e'); } - // 4. Fallback (should ideally not happen if DB is seeded) - return user.uid; + if (businessId == null || businessId.isEmpty) { + throw Exception('No business ID found in session.'); + } + return businessId; } - /// Converts a Data Connect timestamp/string/json to a [DateTime]. - DateTime? toDateTime(dynamic t) { - if (t == null) return null; - DateTime? dt; - if (t is fdc.Timestamp) { - dt = t.toDateTime(); - } else if (t is String) { - dt = DateTime.tryParse(t); - } else { - try { - dt = DateTime.tryParse(t.toJson() as String); - } catch (_) { - try { - dt = DateTime.tryParse(t.toString()); - } catch (e) { - dt = null; + /// Logic to load session data from backend and populate stores. + Future _loadSession(String userId) async { + try { + final role = await fetchUserRole(userId); + if (role == null) return; + + // Load Staff Session if applicable + if (role == 'STAFF' || role == 'BOTH') { + final response = await connector + .getStaffByUserId(userId: userId) + .execute(); + if (response.data.staffs.isNotEmpty) { + final s = response.data.staffs.first; + dc.StaffSessionStore.instance.setSession( + dc.StaffSession( + ownerId: s.ownerId, + staff: domain.Staff( + id: s.id, + authProviderId: s.userId, + name: s.fullName, + email: s.email ?? '', + phone: s.phone, + status: domain.StaffStatus.completedProfile, + address: s.addres, + avatar: s.photoUrl, + ), + ), + ); } } - } - if (dt != null) { - return DateTimeUtils.toDeviceTime(dt); + // Load Client Session if applicable + if (role == 'BUSINESS' || role == 'BOTH') { + final response = await connector + .getBusinessesByUserId(userId: userId) + .execute(); + if (response.data.businesses.isNotEmpty) { + final b = response.data.businesses.first; + dc.ClientSessionStore.instance.setSession( + dc.ClientSession( + business: dc.ClientBusinessSession( + id: b.id, + businessName: b.businessName, + email: b.email, + city: b.city, + contactName: b.contactName, + companyLogoUrl: b.companyLogoUrl, + ), + ), + ); + } + } + } catch (e) { + debugPrint('DataConnectService: Error loading session for $userId: $e'); + } + } + + /// Converts a Data Connect [Timestamp] to a Dart [DateTime] in local time. + /// + /// Firebase Data Connect always stores and returns timestamps in UTC. + /// Calling [toLocal] ensures the result reflects the device's timezone so + /// that shift dates, start/end times, and formatted strings are correct for + /// the end user. + DateTime? toDateTime(dynamic timestamp) { + if (timestamp == null) return null; + if (timestamp is fdc.Timestamp) { + return timestamp.toDateTime().toLocal(); } return null; } - /// Converts a [DateTime] to a Firebase Data Connect [Timestamp]. + /// Converts a Dart [DateTime] to a Data Connect [Timestamp]. + /// + /// Converts the [DateTime] to UTC before creating the [Timestamp]. fdc.Timestamp toTimestamp(DateTime dateTime) { final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return fdc.Timestamp(nanoseconds, seconds); + final int millis = utc.millisecondsSinceEpoch; + final int seconds = millis ~/ 1000; + final int nanos = (millis % 1000) * 1000000; + return fdc.Timestamp(nanos, seconds); } - // --- 3. Unified Execution --- - // Repositories call this to benefit from centralized error handling/logging + /// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp]. + fdc.Timestamp? tryToTimestamp(DateTime? dateTime) { + if (dateTime == null) return null; + return toTimestamp(dateTime); + } + + /// Executes an operation with centralized error handling. Future run( - Future Function() action, { + Future Function() operation, { bool requiresAuthentication = true, }) async { - if (requiresAuthentication && auth.currentUser == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User must be authenticated to perform this action', - ); - } - - return executeProtected(() async { - // Ensure session token is valid and refresh if needed + if (requiresAuthentication) { await ensureSessionValid(); - return action(); - }); - } - - /// Clears the internal cache (e.g., on logout). - void clearCache() { - _cachedStaffId = null; - _cachedBusinessId = null; - } - - /// Handle session sign-out by clearing caches. - void handleSignOut() { - clearCache(); + } + return executeProtected(operation); } + /// Implementation for SessionHandlerMixin. @override Future fetchUserRole(String userId) async { try { - final fdc.QueryResult - response = await executeProtected( - () => connector.getUserById(id: userId).execute(), - ); + final response = await connector.getUserById(id: userId).execute(); return response.data.user?.userRole; } catch (e) { - debugPrint('Failed to fetch user role: $e'); return null; } } - /// Dispose all resources (call on app shutdown). - Future dispose() async { - await disposeSessionHandler(); + /// Clears Cached Repositories and Session data. + void clearCache() { + _reportsRepository = null; + _shiftsRepository = null; + _hubsRepository = null; + _billingRepository = null; + _coverageRepository = null; + _staffRepository = null; + + dc.StaffSessionStore.instance.clear(); + dc.ClientSessionStore.instance.clear(); } } diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart index 393f4b8a..d04a2cb3 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -96,7 +96,7 @@ mixin SessionHandlerMixin { _authStateSubscription = auth.authStateChanges().listen( (firebase_auth.User? user) async { if (user == null) { - _handleSignOut(); + handleSignOut(); } else { await _handleSignIn(user); } @@ -235,7 +235,7 @@ mixin SessionHandlerMixin { } /// Handle user sign-out event. - void _handleSignOut() { + void handleSignOut() { _emitSessionState(SessionState.unauthenticated()); } diff --git a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart index 529277ea..fbab38fe 100644 --- a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart +++ b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart @@ -1,10 +1,4 @@ class ClientBusinessSession { - final String id; - final String businessName; - final String? email; - final String? city; - final String? contactName; - final String? companyLogoUrl; const ClientBusinessSession({ required this.id, @@ -14,15 +8,23 @@ class ClientBusinessSession { this.contactName, this.companyLogoUrl, }); + final String id; + final String businessName; + final String? email; + final String? city; + final String? contactName; + final String? companyLogoUrl; } class ClientSession { - final ClientBusinessSession? business; const ClientSession({required this.business}); + final ClientBusinessSession? business; } class ClientSessionStore { + + ClientSessionStore._(); ClientSession? _session; ClientSession? get session => _session; @@ -36,6 +38,4 @@ class ClientSessionStore { } static final ClientSessionStore instance = ClientSessionStore._(); - - ClientSessionStore._(); } diff --git a/apps/mobile/packages/data_connect/pubspec.yaml b/apps/mobile/packages/data_connect/pubspec.yaml index 48d0039b..374204e5 100644 --- a/apps/mobile/packages/data_connect/pubspec.yaml +++ b/apps/mobile/packages/data_connect/pubspec.yaml @@ -13,8 +13,9 @@ dependencies: sdk: flutter krow_domain: path: ../domain + krow_core: + path: ../core flutter_modular: ^6.3.0 firebase_data_connect: ^0.2.2+2 firebase_core: ^4.4.0 firebase_auth: ^6.1.4 - krow_core: ^0.0.1 diff --git a/apps/mobile/packages/design_system/lib/src/ui_colors.dart b/apps/mobile/packages/design_system/lib/src/ui_colors.dart index 30a56dc3..1613e791 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_colors.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_colors.dart @@ -21,8 +21,8 @@ class UiColors { /// Foreground color on primary background (#F7FAFC) static const Color primaryForeground = Color(0xFFF7FAFC); - /// Inverse primary color (#9FABF1) - static const Color primaryInverse = Color(0xFF9FABF1); + /// Inverse primary color (#0A39DF) + static const Color primaryInverse = Color.fromARGB(23, 10, 56, 223); /// Secondary background color (#F1F3F5) static const Color secondary = Color(0xFFF1F3F5); @@ -113,6 +113,9 @@ class UiColors { /// Inactive text (#9CA3AF) static const Color textInactive = Color(0xFF9CA3AF); + /// Disabled text color (#9CA3AF) + static const Color textDisabled = textInactive; + /// Placeholder text (#9CA3AF) static const Color textPlaceholder = Color(0xFF9CA3AF); @@ -151,6 +154,9 @@ class UiColors { /// Inactive icon (#D1D5DB) static const Color iconInactive = Color(0xFFD1D5DB); + /// Disabled icon color (#D1D5DB) + static const Color iconDisabled = iconInactive; + /// Active icon (#0A39DF) static const Color iconActive = primary; diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index cd813769..537ef4f7 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -28,6 +28,9 @@ class UiIcons { /// Calendar icon for shifts or schedules static const IconData calendar = _IconLib.calendar; + /// Calender check icon for shifts or schedules + static const IconData calendarCheck = _IconLib.calendarCheck; + /// Briefcase icon for jobs static const IconData briefcase = _IconLib.briefcase; @@ -127,6 +130,9 @@ class UiIcons { /// Wallet icon static const IconData wallet = _IconLib.wallet; + /// Bank icon + static const IconData bank = _IconLib.landmark; + /// Credit card icon static const IconData creditCard = _IconLib.creditCard; @@ -184,6 +190,9 @@ class UiIcons { /// Trending down icon for insights static const IconData trendingDown = _IconLib.trendingDown; + /// Trending up icon for insights + static const IconData trendingUp = _IconLib.trendingUp; + /// Target icon for metrics static const IconData target = _IconLib.target; @@ -264,4 +273,10 @@ class UiIcons { /// Chef hat icon for attire static const IconData chefHat = _IconLib.chefHat; + + /// Help circle icon for FAQs + static const IconData helpCircle = _IconLib.helpCircle; + + /// Gallery icon for gallery + static const IconData gallery = _IconLib.galleryVertical; } diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index 919a78a0..5b346793 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -71,7 +71,9 @@ class UiTheme { ), maximumSize: const Size(double.infinity, 54), ).copyWith( - side: WidgetStateProperty.resolveWith((Set states) { + side: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.disabled)) { return const BorderSide( color: UiColors.borderPrimary, @@ -80,9 +82,12 @@ class UiTheme { } return null; }), - overlayColor: WidgetStateProperty.resolveWith((Set states) { - if (states.contains(WidgetState.hovered)) + overlayColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.hovered)) { return UiColors.buttonPrimaryHover; + } return null; }), ), @@ -239,7 +244,9 @@ class UiTheme { navigationBarTheme: NavigationBarThemeData( backgroundColor: UiColors.white, indicatorColor: UiColors.primaryInverse.withAlpha(51), // 20% of 255 - labelTextStyle: WidgetStateProperty.resolveWith((Set states) { + labelTextStyle: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.selected)) { return UiTypography.footnote2m.textPrimary; } @@ -249,13 +256,38 @@ class UiTheme { // Switch Theme switchTheme: SwitchThemeData( - trackColor: WidgetStateProperty.resolveWith((Set states) { + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { if (states.contains(WidgetState.selected)) { - return UiColors.switchActive; + return UiColors.primary.withAlpha(60); } return UiColors.switchInactive; }), - thumbColor: const WidgetStatePropertyAll(UiColors.white), + thumbColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return UiColors.primary; + } + return UiColors.white; + }), + trackOutlineColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return UiColors.primary; + } + return UiColors.transparent; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return 1.0; + } + return 0.0; + }), ), // Checkbox Theme diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index b2224b11..8e1ce9bb 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -205,6 +205,14 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Headline 3 Bold - Font: Instrument Sans, Size: 22, Height: 1.5 (#121826) + static final TextStyle headline3b = _primaryBase.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + height: 1.5, + color: UiColors.textPrimary, + ); + /// Headline 4 Medium - Font: Instrument Sans, Size: 22, Height: 1.5 (#121826) static final TextStyle headline4m = _primaryBase.copyWith( fontWeight: FontWeight.w500, @@ -221,6 +229,14 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Headline 4 Bold - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826) + static final TextStyle headline4b = _primaryBase.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18, + height: 1.5, + color: UiColors.textPrimary, + ); + /// Headline 5 Regular - Font: Instrument Sans, Size: 18, Height: 1.5 (#121826) static final TextStyle headline5r = _primaryBase.copyWith( fontWeight: FontWeight.w400, @@ -346,10 +362,19 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Body 3 Bold - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: -0.1 (#121826) + static final TextStyle body3b = _primaryBase.copyWith( + fontWeight: FontWeight.w700, + fontSize: 12, + height: 1.5, + letterSpacing: -0.1, + color: UiColors.textPrimary, + ); + /// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) static final TextStyle body4r = _primaryBase.copyWith( fontWeight: FontWeight.w400, - fontSize: 12, + fontSize: 10, height: 1.5, letterSpacing: 0.05, color: UiColors.textPrimary, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart index 2af61b8b..46654038 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart @@ -1,14 +1,30 @@ +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../ui_icons.dart'; - /// A custom AppBar for the Krow UI design system. /// /// This widget provides a consistent look and feel for top app bars across the application. class UiAppBar extends StatelessWidget implements PreferredSizeWidget { + const UiAppBar({ + super.key, + this.title, + this.subtitle, + this.titleWidget, + this.leading, + this.actions, + this.height = kToolbarHeight, + this.centerTitle = false, + this.onLeadingPressed, + this.showBackButton = true, + this.bottom, + }); + /// The title text to display in the app bar. final String? title; + /// The subtitle text to display in the app bar. + final String? subtitle; + /// A widget to display instead of the title text. final Widget? titleWidget; @@ -36,33 +52,34 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget { /// Typically a [TabBar]. Only widgets that implement [PreferredSizeWidget] can be used at the bottom of an app bar. final PreferredSizeWidget? bottom; - const UiAppBar({ - super.key, - this.title, - this.titleWidget, - this.leading, - this.actions, - this.height = kToolbarHeight, - this.centerTitle = true, - this.onLeadingPressed, - this.showBackButton = true, - this.bottom, - }); - @override Widget build(BuildContext context) { return AppBar( - title: titleWidget ?? + title: + titleWidget ?? (title != null - ? Text( - title!, + ? Column( + crossAxisAlignment: centerTitle + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title!, style: UiTypography.headline4b), + if (subtitle != null) + Text(subtitle!, style: UiTypography.body3r.textSecondary), + ], ) : null), - leading: leading ?? + leading: + leading ?? (showBackButton - ? IconButton( - icon: const Icon(UiIcons.chevronLeft, size: 20), - onPressed: onLeadingPressed ?? () => Navigator.of(context).pop(), + ? UiIconButton( + icon: UiIcons.chevronLeft, + onTap: onLeadingPressed ?? () => Navigator.of(context).pop(), + backgroundColor: UiColors.transparent, + iconColor: UiColors.iconThird, + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), ) : null), actions: actions, @@ -72,5 +89,6 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget { } @override - Size get preferredSize => Size.fromHeight(height + (bottom?.preferredSize.height ?? 0.0)); + Size get preferredSize => + Size.fromHeight(height + (bottom?.preferredSize.height ?? 0.0)); } diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart index 68f16e49..bfa6ceaf 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart @@ -3,6 +3,96 @@ import '../ui_constants.dart'; /// A custom button widget with different variants and icon support. class UiButton extends StatelessWidget { + + /// Creates a [UiButton] with a custom button builder. + const UiButton({ + super.key, + this.text, + this.child, + required this.buttonBuilder, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.large, + this.fullWidth = false, + }) : assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a primary button using [ElevatedButton]. + const UiButton.primary({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.large, + this.fullWidth = false, + }) : buttonBuilder = _elevatedButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a secondary button using [OutlinedButton]. + const UiButton.secondary({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.large, + this.fullWidth = false, + }) : buttonBuilder = _outlinedButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a text button using [TextButton]. + const UiButton.text({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.large, + this.fullWidth = false, + }) : buttonBuilder = _textButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a ghost button (transparent background). + const UiButton.ghost({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.large, + this.fullWidth = false, + }) : buttonBuilder = _textButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); /// The text to display on the button. final String? text; @@ -39,100 +129,10 @@ class UiButton extends StatelessWidget { ) buttonBuilder; - /// Creates a [UiButton] with a custom button builder. - const UiButton({ - super.key, - this.text, - this.child, - required this.buttonBuilder, - this.onPressed, - this.leadingIcon, - this.trailingIcon, - this.style, - this.iconSize = 20, - this.size = UiButtonSize.large, - this.fullWidth = false, - }) : assert( - text != null || child != null, - 'Either text or child must be provided', - ); - - /// Creates a primary button using [ElevatedButton]. - UiButton.primary({ - super.key, - this.text, - this.child, - this.onPressed, - this.leadingIcon, - this.trailingIcon, - this.style, - this.iconSize = 20, - this.size = UiButtonSize.large, - this.fullWidth = false, - }) : buttonBuilder = _elevatedButtonBuilder, - assert( - text != null || child != null, - 'Either text or child must be provided', - ); - - /// Creates a secondary button using [OutlinedButton]. - UiButton.secondary({ - super.key, - this.text, - this.child, - this.onPressed, - this.leadingIcon, - this.trailingIcon, - this.style, - this.iconSize = 20, - this.size = UiButtonSize.large, - this.fullWidth = false, - }) : buttonBuilder = _outlinedButtonBuilder, - assert( - text != null || child != null, - 'Either text or child must be provided', - ); - - /// Creates a text button using [TextButton]. - UiButton.text({ - super.key, - this.text, - this.child, - this.onPressed, - this.leadingIcon, - this.trailingIcon, - this.style, - this.iconSize = 20, - this.size = UiButtonSize.large, - this.fullWidth = false, - }) : buttonBuilder = _textButtonBuilder, - assert( - text != null || child != null, - 'Either text or child must be provided', - ); - - /// Creates a ghost button (transparent background). - UiButton.ghost({ - super.key, - this.text, - this.child, - this.onPressed, - this.leadingIcon, - this.trailingIcon, - this.style, - this.iconSize = 20, - this.size = UiButtonSize.large, - this.fullWidth = false, - }) : buttonBuilder = _textButtonBuilder, - assert( - text != null || child != null, - 'Either text or child must be provided', - ); - @override /// Builds the button UI. Widget build(BuildContext context) { - final ButtonStyle? mergedStyle = style != null + final ButtonStyle mergedStyle = style != null ? _getSizeStyle().merge(style) : _getSizeStyle(); diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart index f7bd0177..09a781da 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart @@ -5,6 +5,9 @@ import '../ui_typography.dart'; /// Sizes for the [UiChip] widget. enum UiChipSize { + // X-Small size (e.g. for tags in tight spaces). + xSmall, + /// Small size (e.g. for tags in tight spaces). small, @@ -25,10 +28,26 @@ enum UiChipVariant { /// Accent style with highlight background. accent, + + /// Desructive style with red background. + destructive, } /// A custom chip widget with supports for different sizes, themes, and icons. class UiChip extends StatelessWidget { + /// Creates a [UiChip]. + const UiChip({ + super.key, + required this.label, + this.size = UiChipSize.medium, + this.variant = UiChipVariant.secondary, + this.leadingIcon, + this.trailingIcon, + this.onTap, + this.onTrailingIconTap, + this.isSelected = false, + }); + /// The text label to display. final String label; @@ -53,19 +72,6 @@ class UiChip extends StatelessWidget { /// Whether the chip is currently selected/active. final bool isSelected; - /// Creates a [UiChip]. - const UiChip({ - super.key, - required this.label, - this.size = UiChipSize.medium, - this.variant = UiChipVariant.secondary, - this.leadingIcon, - this.trailingIcon, - this.onTap, - this.onTrailingIconTap, - this.isSelected = false, - }); - @override Widget build(BuildContext context) { final Color backgroundColor = _getBackgroundColor(); @@ -99,7 +105,7 @@ class UiChip extends StatelessWidget { padding: padding, decoration: BoxDecoration( color: backgroundColor, - borderRadius: UiConstants.radiusFull, + borderRadius: UiConstants.radiusMd, border: _getBorder(), ), child: content, @@ -119,6 +125,8 @@ class UiChip extends StatelessWidget { return UiColors.tagInProgress; case UiChipVariant.accent: return UiColors.accent; + case UiChipVariant.destructive: + return UiColors.iconError.withValues(alpha: 0.1); } } @@ -134,11 +142,15 @@ class UiChip extends StatelessWidget { return UiColors.primary; case UiChipVariant.accent: return UiColors.accentForeground; + case UiChipVariant.destructive: + return UiColors.iconError; } } TextStyle _getTextStyle() { switch (size) { + case UiChipSize.xSmall: + return UiTypography.body4r; case UiChipSize.small: return UiTypography.body3r; case UiChipSize.medium: @@ -150,6 +162,8 @@ class UiChip extends StatelessWidget { EdgeInsets _getPadding() { switch (size) { + case UiChipSize.xSmall: + return const EdgeInsets.symmetric(horizontal: 6, vertical: 4); case UiChipSize.small: return const EdgeInsets.symmetric(horizontal: 10, vertical: 6); case UiChipSize.medium: @@ -161,6 +175,8 @@ class UiChip extends StatelessWidget { double _getIconSize() { switch (size) { + case UiChipSize.xSmall: + return 10; case UiChipSize.small: return 12; case UiChipSize.medium: @@ -172,6 +188,8 @@ class UiChip extends StatelessWidget { double _getGap() { switch (size) { + case UiChipSize.xSmall: + return UiConstants.space1; case UiChipSize.small: return UiConstants.space1; case UiChipSize.medium: diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_icon_button.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_icon_button.dart index d49ac67d..dca4aff9 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_icon_button.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_icon_button.dart @@ -5,6 +5,46 @@ import '../ui_constants.dart'; /// A custom icon button with blur effect and different variants. class UiIconButton extends StatelessWidget { + + /// Creates a [UiIconButton] with custom properties. + const UiIconButton({ + super.key, + required this.icon, + this.size = 40, + this.iconSize = 20, + required this.backgroundColor, + required this.iconColor, + this.useBlur = false, + this.onTap, + this.shape = BoxShape.circle, + this.borderRadius, + }); + + /// Creates a primary variant icon button with solid background. + const UiIconButton.primary({ + super.key, + required this.icon, + this.size = 40, + this.iconSize = 20, + this.onTap, + this.shape = BoxShape.circle, + this.borderRadius, + }) : backgroundColor = UiColors.primary, + iconColor = UiColors.white, + useBlur = false; + + /// Creates a secondary variant icon button with blur effect. + UiIconButton.secondary({ + super.key, + required this.icon, + this.size = 40, + this.iconSize = 20, + this.onTap, + this.shape = BoxShape.circle, + this.borderRadius, + }) : backgroundColor = UiColors.primary.withAlpha(96), + iconColor = UiColors.primary, + useBlur = true; /// The icon to display. final IconData icon; @@ -26,39 +66,11 @@ class UiIconButton extends StatelessWidget { /// Callback when the button is tapped. final VoidCallback? onTap; - /// Creates a [UiIconButton] with custom properties. - const UiIconButton({ - super.key, - required this.icon, - this.size = 40, - this.iconSize = 20, - required this.backgroundColor, - required this.iconColor, - this.useBlur = false, - this.onTap, - }); + /// The shape of the button (circle or rectangle). + final BoxShape shape; - /// Creates a primary variant icon button with solid background. - const UiIconButton.primary({ - super.key, - required this.icon, - this.size = 40, - this.iconSize = 20, - this.onTap, - }) : backgroundColor = UiColors.primary, - iconColor = UiColors.white, - useBlur = false; - - /// Creates a secondary variant icon button with blur effect. - UiIconButton.secondary({ - super.key, - required this.icon, - this.size = 40, - this.iconSize = 20, - this.onTap, - }) : backgroundColor = UiColors.primary.withAlpha(96), - iconColor = UiColors.primary, - useBlur = true; + /// The border radius for rectangle shape. + final BorderRadius? borderRadius; @override /// Builds the icon button UI. @@ -66,7 +78,11 @@ class UiIconButton extends StatelessWidget { final Widget button = Container( width: size, height: size, - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + decoration: BoxDecoration( + color: backgroundColor, + shape: shape, + borderRadius: shape == BoxShape.rectangle ? borderRadius : null, + ), child: Icon(icon, color: iconColor, size: iconSize), ); diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart index 868df5c8..9ae7ff61 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart @@ -8,6 +8,27 @@ import '../ui_colors.dart'; /// /// This widget combines a label and a [TextField] with consistent styling. class UiTextField extends StatelessWidget { + + const UiTextField({ + super.key, + this.label, + this.hintText, + this.onChanged, + this.controller, + this.keyboardType, + this.maxLines = 1, + this.obscureText = false, + this.textInputAction, + this.onSubmitted, + this.autofocus = false, + this.inputFormatters, + this.prefixIcon, + this.suffixIcon, + this.suffix, + this.readOnly = false, + this.onTap, + this.validator, + }); /// The label text to display above the text field. final String? label; @@ -56,25 +77,8 @@ class UiTextField extends StatelessWidget { /// Callback when the text field is tapped. final VoidCallback? onTap; - const UiTextField({ - super.key, - this.label, - this.hintText, - this.onChanged, - this.controller, - this.keyboardType, - this.maxLines = 1, - this.obscureText = false, - this.textInputAction, - this.onSubmitted, - this.autofocus = false, - this.inputFormatters, - this.prefixIcon, - this.suffixIcon, - this.suffix, - this.readOnly = false, - this.onTap, - }); + /// Optional validator for the text field. + final String? Function(String?)? validator; @override Widget build(BuildContext context) { @@ -86,18 +90,19 @@ class UiTextField extends StatelessWidget { Text(label!, style: UiTypography.body4m.textSecondary), const SizedBox(height: UiConstants.space1), ], - TextField( + TextFormField( controller: controller, onChanged: onChanged, keyboardType: keyboardType, maxLines: maxLines, obscureText: obscureText, textInputAction: textInputAction, - onSubmitted: onSubmitted, + onFieldSubmitted: onSubmitted, autofocus: autofocus, inputFormatters: inputFormatters, readOnly: readOnly, onTap: onTap, + validator: validator, style: UiTypography.body1r.textPrimary, decoration: InputDecoration( hintText: hintText, diff --git a/apps/mobile/packages/design_system/pubspec.yaml b/apps/mobile/packages/design_system/pubspec.yaml index 6bd42bb3..0979764c 100644 --- a/apps/mobile/packages/design_system/pubspec.yaml +++ b/apps/mobile/packages/design_system/pubspec.yaml @@ -15,8 +15,6 @@ dependencies: google_fonts: ^7.0.2 lucide_icons: ^0.257.0 font_awesome_flutter: ^10.7.0 - core_localization: - path: ../core_localization dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index d3b2ac2a..87167b9e 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -6,6 +6,15 @@ /// Note: Repository Interfaces are now located in their respective Feature packages. library; +// Core +export 'src/core/services/api_services/api_response.dart'; +export 'src/core/services/api_services/base_api_service.dart'; +export 'src/core/services/api_services/base_core_service.dart'; +export 'src/core/services/api_services/file_visibility.dart'; + +// Device +export 'src/core/services/device/base_device_service.dart'; + // Users & Membership export 'src/entities/users/user.dart'; export 'src/entities/users/staff.dart'; @@ -19,6 +28,7 @@ export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; export 'src/entities/business/hub_department.dart'; export 'src/entities/business/vendor.dart'; +export 'src/entities/business/cost_center.dart'; // Events & Assignments export 'src/entities/events/event.dart'; @@ -34,10 +44,15 @@ export 'src/entities/shifts/break/break.dart'; export 'src/adapters/shifts/break/break_adapter.dart'; // Orders & Requests -export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/one_time_order.dart'; export 'src/entities/orders/one_time_order_position.dart'; +export 'src/entities/orders/recurring_order.dart'; +export 'src/entities/orders/recurring_order_position.dart'; +export 'src/entities/orders/permanent_order.dart'; +export 'src/entities/orders/permanent_order_position.dart'; +export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/order_item.dart'; +export 'src/entities/orders/reorder_data.dart'; // Skills & Certs export 'src/entities/skills/skill.dart'; @@ -47,12 +62,14 @@ export 'src/entities/skills/certificate.dart'; export 'src/entities/skills/skill_kit.dart'; // Financial & Payroll +export 'src/entities/benefits/benefit.dart'; export 'src/entities/financial/invoice.dart'; export 'src/entities/financial/time_card.dart'; export 'src/entities/financial/invoice_item.dart'; export 'src/entities/financial/invoice_decline.dart'; export 'src/entities/financial/staff_payment.dart'; export 'src/entities/financial/payment_summary.dart'; +export 'src/entities/financial/billing_period.dart'; export 'src/entities/financial/bank_account/bank_account.dart'; export 'src/entities/financial/bank_account/business_bank_account.dart'; export 'src/entities/financial/bank_account/staff_bank_account.dart'; @@ -61,6 +78,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart'; // Profile export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/attire_item.dart'; +export 'src/entities/profile/attire_verification_status.dart'; export 'src/entities/profile/relationship_type.dart'; export 'src/entities/profile/industry.dart'; export 'src/entities/profile/tax_form.dart'; @@ -107,3 +125,12 @@ export 'src/adapters/financial/payment_adapter.dart'; // Exceptions export 'src/exceptions/app_exception.dart'; + +// Reports +export 'src/entities/reports/daily_ops_report.dart'; +export 'src/entities/reports/spend_report.dart'; +export 'src/entities/reports/coverage_report.dart'; +export 'src/entities/reports/forecast_report.dart'; +export 'src/entities/reports/no_show_report.dart'; +export 'src/entities/reports/performance_report.dart'; +export 'src/entities/reports/reports_summary.dart'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart index f32724f1..f06ddeeb 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart @@ -2,18 +2,18 @@ import '../../entities/availability/availability_slot.dart'; /// Adapter for [AvailabilitySlot] domain entity. class AvailabilityAdapter { - static const Map> _slotDefinitions = { - 'MORNING': { + static const Map> _slotDefinitions = >{ + 'MORNING': { 'id': 'morning', 'label': 'Morning', 'timeRange': '4:00 AM - 12:00 PM', }, - 'AFTERNOON': { + 'AFTERNOON': { 'id': 'afternoon', 'label': 'Afternoon', 'timeRange': '12:00 PM - 6:00 PM', }, - 'EVENING': { + 'EVENING': { 'id': 'evening', 'label': 'Evening', 'timeRange': '6:00 PM - 12:00 AM', @@ -22,7 +22,7 @@ class AvailabilityAdapter { /// Converts a backend slot name (e.g. 'MORNING') to a Domain [AvailabilitySlot]. static AvailabilitySlot fromPrimitive(String slotName, {bool isAvailable = false}) { - final def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!; + final Map def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!; return AvailabilitySlot( id: def['id']!, label: def['label']!, diff --git a/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart index 3ebfad03..049dd3cd 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart @@ -1,4 +1,3 @@ -import '../../entities/shifts/shift.dart'; import '../../entities/clock_in/attendance_status.dart'; /// Adapter for Clock In related data. diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart index 8c070da4..8d4c8f6e 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart @@ -18,7 +18,7 @@ class TaxFormAdapter { final TaxFormType formType = _stringToType(type); final TaxFormStatus formStatus = _stringToStatus(status); final Map formDetails = - formData is Map ? Map.from(formData as Map) : {}; + formData is Map ? Map.from(formData) : {}; if (formType == TaxFormType.i9) { return I9TaxForm( diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart new file mode 100644 index 00000000..3e6a5435 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart @@ -0,0 +1,22 @@ +/// Represents a standardized response from the API. +class ApiResponse { + /// Creates an [ApiResponse]. + const ApiResponse({ + required this.code, + required this.message, + this.data, + this.errors = const {}, + }); + + /// The response code (e.g., '200', '404', or custom error code). + final String code; + + /// A descriptive message about the response. + final String message; + + /// The payload returned by the API. + final dynamic data; + + /// A map of field-specific error messages, if any. + final Map errors; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart new file mode 100644 index 00000000..ef9ccef6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart @@ -0,0 +1,30 @@ +import 'api_response.dart'; + +/// Abstract base class for API services. +/// +/// This defines the contract for making HTTP requests. +abstract class BaseApiService { + /// Performs a GET request to the specified [endpoint]. + Future get(String endpoint, {Map? params}); + + /// Performs a POST request to the specified [endpoint]. + Future post( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PUT request to the specified [endpoint]. + Future put( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PATCH request to the specified [endpoint]. + Future patch( + String endpoint, { + dynamic data, + Map? params, + }); +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart new file mode 100644 index 00000000..1acda2e3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart @@ -0,0 +1,29 @@ +import 'api_response.dart'; +import 'base_api_service.dart'; + +/// Abstract base class for core business services. +/// +/// This provides a common [action] wrapper for standardized execution +/// and error catching across all core service implementations. +abstract class BaseCoreService { + /// Creates a [BaseCoreService] with the given [api] client. + const BaseCoreService(this.api); + + /// The API client used to perform requests. + final BaseApiService api; + + /// Standardized wrapper to execute API actions. + /// + /// This handles generic error normalization for unexpected non-HTTP errors. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + return ApiResponse( + code: 'CORE_INTERNAL_ERROR', + message: e.toString(), + errors: {'exception': e.runtimeType.toString()}, + ); + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart new file mode 100644 index 00000000..2b0d7dd0 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart @@ -0,0 +1,14 @@ +/// Represents the accessibility level of an uploaded file. +enum FileVisibility { + /// File is accessible only to authenticated owners/authorized users. + private('private'), + + /// File is accessible publicly via its URL. + public('public'); + + /// Creates a [FileVisibility]. + const FileVisibility(this.value); + + /// The string value expected by the backend. + final String value; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart new file mode 100644 index 00000000..b8f030fc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart @@ -0,0 +1,22 @@ +/// Abstract base class for device-related services. +/// +/// Device services handle native hardware/platform interactions +/// like Camera, Gallery, Location, or Biometrics. +abstract class BaseDeviceService { + const BaseDeviceService(); + + /// Standardized wrapper to execute device actions. + /// + /// This can be used for common handling like logging device interactions + /// or catching native platform exceptions. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + // Re-throw or handle based on project preference. + // For device services, we might want to throw specific + // DeviceExceptions later. + rethrow; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart b/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart index 45d7ef01..b0085bed 100644 --- a/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart +++ b/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart @@ -2,10 +2,6 @@ import 'package:equatable/equatable.dart'; /// Represents a specific time slot within a day (e.g., Morning, Afternoon, Evening). class AvailabilitySlot extends Equatable { - final String id; - final String label; - final String timeRange; - final bool isAvailable; const AvailabilitySlot({ required this.id, @@ -13,6 +9,10 @@ class AvailabilitySlot extends Equatable { required this.timeRange, this.isAvailable = true, }); + final String id; + final String label; + final String timeRange; + final bool isAvailable; AvailabilitySlot copyWith({ String? id, @@ -29,5 +29,5 @@ class AvailabilitySlot extends Equatable { } @override - List get props => [id, label, timeRange, isAvailable]; + List get props => [id, label, timeRange, isAvailable]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart b/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart index 6dd7732e..ee285830 100644 --- a/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart +++ b/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart @@ -4,15 +4,15 @@ import 'availability_slot.dart'; /// Represents availability configuration for a specific date. class DayAvailability extends Equatable { - final DateTime date; - final bool isAvailable; - final List slots; const DayAvailability({ required this.date, this.isAvailable = false, - this.slots = const [], + this.slots = const [], }); + final DateTime date; + final bool isAvailable; + final List slots; DayAvailability copyWith({ DateTime? date, @@ -27,5 +27,5 @@ class DayAvailability extends Equatable { } @override - List get props => [date, isAvailable, slots]; + List get props => [date, isAvailable, slots]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart new file mode 100644 index 00000000..26eba20c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a staff member's benefit balance. +class Benefit extends Equatable { + /// The title of the benefit (e.g., Sick Leave, Holiday, Vacation). + final String title; + + /// The total entitlement in hours. + final double entitlementHours; + + /// The hours used so far. + final double usedHours; + + /// The hours remaining. + double get remainingHours => entitlementHours - usedHours; + + /// Creates a [Benefit]. + const Benefit({ + required this.title, + required this.entitlementHours, + required this.usedHours, + }); + + @override + List get props => [title, entitlementHours, usedHours]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart new file mode 100644 index 00000000..8d3d5528 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a financial cost center used for billing and tracking. +class CostCenter extends Equatable { + const CostCenter({ + required this.id, + required this.name, + this.code, + }); + + /// Unique identifier. + final String id; + + /// Display name of the cost center. + final String name; + + /// Optional alphanumeric code associated with this cost center. + final String? code; + + @override + List get props => [id, name, code]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart index 4070a28a..79c06572 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'cost_center.dart'; + /// The status of a [Hub]. enum HubStatus { /// Fully operational. @@ -14,7 +16,6 @@ enum HubStatus { /// Represents a branch location or operational unit within a [Business]. class Hub extends Equatable { - const Hub({ required this.id, required this.businessId, @@ -22,6 +23,7 @@ class Hub extends Equatable { required this.address, this.nfcTagId, required this.status, + this.costCenter, }); /// Unique identifier. final String id; @@ -41,6 +43,9 @@ class Hub extends Equatable { /// Operational status. final HubStatus status; + /// Assigned cost center for this hub. + final CostCenter? costCenter; + @override - List get props => [id, businessId, name, address, nfcTagId, status]; + List get props => [id, businessId, name, address, nfcTagId, status, costCenter]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart index 84acf58e..3d6bc3e1 100644 --- a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart @@ -2,11 +2,6 @@ import 'package:equatable/equatable.dart'; /// Simple entity to hold attendance state class AttendanceStatus extends Equatable { - final bool isCheckedIn; - final DateTime? checkInTime; - final DateTime? checkOutTime; - final String? activeShiftId; - final String? activeApplicationId; const AttendanceStatus({ this.isCheckedIn = false, @@ -15,9 +10,14 @@ class AttendanceStatus extends Equatable { this.activeShiftId, this.activeApplicationId, }); + final bool isCheckedIn; + final DateTime? checkInTime; + final DateTime? checkOutTime; + final String? activeShiftId; + final String? activeApplicationId; @override - List get props => [ + List get props => [ isCheckedIn, checkInTime, checkOutTime, diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart b/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart new file mode 100644 index 00000000..c26a4108 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart @@ -0,0 +1,8 @@ +/// Defines the period for billing calculations. +enum BillingPeriod { + /// Weekly billing period. + week, + + /// Monthly billing period. + month, +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart index 4c5a0e3c..64341884 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart @@ -37,6 +37,12 @@ class Invoice extends Equatable { required this.addonsAmount, this.invoiceNumber, this.issueDate, + this.title, + this.clientName, + this.locationAddress, + this.staffCount, + this.totalHours, + this.workers = const [], }); /// Unique identifier. final String id; @@ -65,6 +71,24 @@ class Invoice extends Equatable { /// Date when the invoice was issued. final DateTime? issueDate; + /// Human-readable title (e.g. event name). + final String? title; + + /// Name of the client business. + final String? clientName; + + /// Address of the event/location. + final String? locationAddress; + + /// Number of staff worked. + final int? staffCount; + + /// Total hours worked. + final double? totalHours; + + /// List of workers associated with this invoice. + final List workers; + @override List get props => [ id, @@ -76,5 +100,49 @@ class Invoice extends Equatable { addonsAmount, invoiceNumber, issueDate, + title, + clientName, + locationAddress, + staffCount, + totalHours, + workers, + ]; +} + +/// Represents a worker entry in an [Invoice]. +class InvoiceWorker extends Equatable { + const InvoiceWorker({ + required this.name, + required this.role, + required this.amount, + required this.hours, + required this.rate, + this.checkIn, + this.checkOut, + this.breakMinutes = 0, + this.avatarUrl, + }); + + final String name; + final String role; + final double amount; + final double hours; + final double rate; + final DateTime? checkIn; + final DateTime? checkOut; + final int breakMinutes; + final String? avatarUrl; + + @override + List get props => [ + name, + role, + amount, + hours, + rate, + checkIn, + checkOut, + breakMinutes, + avatarUrl, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart b/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart index 0a202449..5a905853 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart @@ -2,10 +2,6 @@ import 'package:equatable/equatable.dart'; /// Summary of staff earnings. class PaymentSummary extends Equatable { - final double weeklyEarnings; - final double monthlyEarnings; - final double pendingEarnings; - final double totalEarnings; const PaymentSummary({ required this.weeklyEarnings, @@ -13,9 +9,13 @@ class PaymentSummary extends Equatable { required this.pendingEarnings, required this.totalEarnings, }); + final double weeklyEarnings; + final double monthlyEarnings; + final double pendingEarnings; + final double totalEarnings; @override - List get props => [ + List get props => [ weeklyEarnings, monthlyEarnings, pendingEarnings, diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart index 77bcb4ae..bb70cdd7 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart @@ -23,6 +23,21 @@ enum TimeCardStatus { /// Represents a time card for a staff member. class TimeCard extends Equatable { + + /// Creates a [TimeCard]. + const TimeCard({ + required this.id, + required this.shiftTitle, + required this.clientName, + required this.date, + required this.startTime, + required this.endTime, + required this.totalHours, + required this.hourlyRate, + required this.totalPay, + required this.status, + this.location, + }); /// Unique identifier of the time card (often matches Application ID). final String id; /// Title of the shift. @@ -46,23 +61,8 @@ class TimeCard extends Equatable { /// Location name. final String? location; - /// Creates a [TimeCard]. - const TimeCard({ - required this.id, - required this.shiftTitle, - required this.clientName, - required this.date, - required this.startTime, - required this.endTime, - required this.totalHours, - required this.hourlyRate, - required this.totalPay, - required this.status, - this.location, - }); - @override - List get props => [ + List get props => [ id, shiftTitle, clientName, diff --git a/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart b/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart index 7d9e22a3..d13a80bd 100644 --- a/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart @@ -1,46 +1,51 @@ import 'package:equatable/equatable.dart'; -/// Summary of a completed shift role used for reorder suggestions. +/// Summary of a completed order used for reorder suggestions. class ReorderItem extends Equatable { const ReorderItem({ required this.orderId, required this.title, required this.location, - required this.hourlyRate, - required this.hours, + required this.totalCost, required this.workers, required this.type, + this.hourlyRate = 0, + this.hours = 0, }); - /// Parent order id for the completed shift. + /// Unique identifier of the order. final String orderId; - /// Display title (role + shift title). + /// Display title of the order (e.g., event name or first shift title). final String title; - /// Location from the shift. + /// Location of the order (e.g., first shift location). final String location; - /// Hourly rate from the role. - final double hourlyRate; + /// Total calculated cost for the order. + final double totalCost; - /// Total hours for the shift role. - final double hours; - - /// Worker count for the shift role. + /// Total number of workers required for the order. final int workers; - /// Order type (e.g., ONE_TIME). + /// The type of order (e.g., ONE_TIME, RECURRING). final String type; + /// Average or primary hourly rate (optional, for display). + final double hourlyRate; + + /// Total hours for the order (optional, for display). + final double hours; + @override List get props => [ orderId, title, location, - hourlyRate, - hours, + totalCost, workers, type, + hourlyRate, + hours, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart index e0e7ca67..fe50bd20 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart @@ -13,6 +13,7 @@ class OneTimeOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); /// The specific date for the shift or event. @@ -33,6 +34,9 @@ class OneTimeOrder extends Equatable { /// Selected vendor id for this order. final String? vendorId; + /// Optional hub manager id. + final String? hubManagerId; + /// Role hourly rates keyed by role id. final Map roleRates; @@ -44,6 +48,7 @@ class OneTimeOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index 8dea0ee5..88ae8091 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'order_type.dart'; + /// Represents a customer's view of an order or shift. /// /// This entity captures the details necessary for the dashboard/view orders screen, @@ -9,6 +11,7 @@ class OrderItem extends Equatable { const OrderItem({ required this.id, required this.orderId, + required this.orderType, required this.title, required this.clientName, required this.status, @@ -20,9 +23,12 @@ class OrderItem extends Equatable { required this.filled, required this.workersNeeded, required this.hourlyRate, + required this.eventName, this.hours = 0, this.totalValue = 0, this.confirmedApps = const >[], + this.hubManagerId, + this.hubManagerName, }); /// Unique identifier of the order. @@ -31,6 +37,9 @@ class OrderItem extends Equatable { /// Parent order identifier. final String orderId; + /// The type of order (e.g., ONE_TIME, PERMANENT). + final OrderType orderType; + /// Title or name of the role. final String title; @@ -70,13 +79,23 @@ class OrderItem extends Equatable { /// Total value for the shift role. final double totalValue; + /// Name of the event. + final String eventName; + /// List of confirmed worker applications. final List> confirmedApps; + /// Optional ID of the assigned hub manager. + final String? hubManagerId; + + /// Optional Name of the assigned hub manager. + final String? hubManagerName; + @override List get props => [ id, orderId, + orderType, title, clientName, status, @@ -90,6 +109,9 @@ class OrderItem extends Equatable { hourlyRate, hours, totalValue, + eventName, confirmedApps, + hubManagerId, + hubManagerName, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart index e1448be7..f4385b5b 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart @@ -1,25 +1,30 @@ -import 'package:equatable/equatable.dart'; +/// Defines the type of an order. +enum OrderType { + /// A single occurrence shift. + oneTime, -/// Represents a type of order that can be created (e.g., Rapid, One-Time). -/// -/// This entity defines the identity and display metadata (keys) for the order type. -/// UI-specific properties like colors and icons are handled by the presentation layer. -class OrderType extends Equatable { + /// A long-term or permanent staffing position. + permanent, - const OrderType({ - required this.id, - required this.titleKey, - required this.descriptionKey, - }); - /// Unique identifier for the order type. - final String id; + /// Shifts that repeat on a defined schedule. + recurring, - /// Translation key for the title. - final String titleKey; + /// A quickly created shift. + rapid; - /// Translation key for the description. - final String descriptionKey; - - @override - List get props => [id, titleKey, descriptionKey]; + /// Creates an [OrderType] from a string value (typically from the backend). + static OrderType fromString(String value) { + switch (value.toUpperCase()) { + case 'ONE_TIME': + return OrderType.oneTime; + case 'PERMANENT': + return OrderType.permanent; + case 'RECURRING': + return OrderType.recurring; + case 'RAPID': + return OrderType.rapid; + default: + return OrderType.oneTime; + } + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart new file mode 100644 index 00000000..ef950f87 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'one_time_order.dart'; +import 'one_time_order_position.dart'; + +/// Represents a customer's request for permanent/ongoing staffing. +class PermanentOrder extends Equatable { + const PermanentOrder({ + required this.startDate, + required this.permanentDays, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.hubManagerId, + this.roleRates = const {}, + }); + + final DateTime startDate; + + /// List of days (e.g., ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']) + final List permanentDays; + + final List positions; + final OneTimeOrderHubDetails? hub; + final String? eventName; + final String? vendorId; + final String? hubManagerId; + final Map roleRates; + + @override + List get props => [ + startDate, + permanentDays, + positions, + hub, + eventName, + vendorId, + hubManagerId, + roleRates, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart new file mode 100644 index 00000000..fb4d1e1b --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [PermanentOrder]. +class PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + this.location, + }); + + /// The job role or title required. + final String role; + + /// The number of workers required for this position. + final int count; + + /// The scheduled start time (e.g., "09:00 AM"). + final String startTime; + + /// The scheduled end time (e.g., "05:00 PM"). + final String endTime; + + /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). + final String lunchBreak; + + /// Optional specific location for this position, if different from the order's main location. + final String? location; + + @override + List get props => [ + role, + count, + startTime, + endTime, + lunchBreak, + location, + ]; + + /// Creates a copy of this position with the given fields replaced. + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return PermanentOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + location: location ?? this.location, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart new file mode 100644 index 00000000..76f00720 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -0,0 +1,106 @@ +import 'package:equatable/equatable.dart'; +import 'recurring_order_position.dart'; + +/// Represents a recurring staffing request spanning a date range. +class RecurringOrder extends Equatable { + const RecurringOrder({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.hubManagerId, + this.roleRates = const {}, + }); + + /// Start date for the recurring schedule. + final DateTime startDate; + + /// End date for the recurring schedule. + final DateTime endDate; + + /// Days of the week to repeat on (e.g., ["S", "M", ...]). + final List recurringDays; + + /// The primary location where the work will take place. + final String location; + + /// The list of positions and headcounts required for this order. + final List positions; + + /// Selected hub details for this order. + final RecurringOrderHubDetails? hub; + + /// Optional order name. + final String? eventName; + + /// Selected vendor id for this order. + final String? vendorId; + + /// Optional hub manager id. + final String? hubManagerId; + + /// Role hourly rates keyed by role id. + final Map roleRates; + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + positions, + hub, + eventName, + vendorId, + hubManagerId, + roleRates, + ]; +} + +/// Minimal hub details used during recurring order creation. +class RecurringOrderHubDetails extends Equatable { + const RecurringOrderHubDetails({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart new file mode 100644 index 00000000..9fdc2161 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [RecurringOrder]. +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + this.location, + }); + + /// The job role or title required. + final String role; + + /// The number of workers required for this position. + final int count; + + /// The scheduled start time (e.g., "09:00 AM"). + final String startTime; + + /// The scheduled end time (e.g., "05:00 PM"). + final String endTime; + + /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). + final String lunchBreak; + + /// Optional specific location for this position, if different from the order's main location. + final String? location; + + @override + List get props => [ + role, + count, + startTime, + endTime, + lunchBreak, + location, + ]; + + /// Creates a copy of this position with the given fields replaced. + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + location: location ?? this.location, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart b/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart new file mode 100644 index 00000000..2f325d3a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart @@ -0,0 +1,76 @@ +import 'package:equatable/equatable.dart'; +import 'one_time_order.dart'; +import 'order_type.dart'; + +/// Represents the full details of an order retrieved for reordering. +class ReorderData extends Equatable { + const ReorderData({ + required this.orderId, + required this.orderType, + required this.eventName, + required this.vendorId, + required this.hub, + required this.positions, + this.date, + this.startDate, + this.endDate, + this.recurringDays = const [], + this.permanentDays = const [], + }); + + final String orderId; + final OrderType orderType; + final String eventName; + final String? vendorId; + final OneTimeOrderHubDetails hub; + final List positions; + + // One-time specific + final DateTime? date; + + // Recurring/Permanent specific + final DateTime? startDate; + final DateTime? endDate; + final List recurringDays; + final List permanentDays; + + @override + List get props => [ + orderId, + orderType, + eventName, + vendorId, + hub, + positions, + date, + startDate, + endDate, + recurringDays, + permanentDays, + ]; +} + +class ReorderPosition extends Equatable { + const ReorderPosition({ + required this.roleId, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + }); + + final String roleId; + final int count; + final String startTime; + final String endTime; + final String lunchBreak; + + @override + List get props => [ + roleId, + count, + startTime, + endTime, + lunchBreak, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart index 97cd9df6..d794ca9e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart @@ -1,17 +1,35 @@ import 'package:equatable/equatable.dart'; +import 'attire_verification_status.dart'; + /// Represents an attire item that a staff member might need or possess. /// /// Attire items are specific clothing or equipment required for jobs. class AttireItem extends Equatable { - /// Unique identifier of the attire item. + /// Creates an [AttireItem]. + const AttireItem({ + required this.id, + required this.code, + required this.label, + this.description, + this.imageUrl, + this.isMandatory = false, + this.verificationStatus, + this.photoUrl, + this.verificationId, + }); + + /// Unique identifier of the attire item (UUID). final String id; + /// String code for the attire item (e.g. BLACK_TSHIRT). + final String code; + /// Display name of the item. final String label; - /// Name of the icon to display (mapped in UI). - final String? iconName; + /// Optional description for the attire item. + final String? description; /// URL of the reference image. final String? imageUrl; @@ -19,15 +37,50 @@ class AttireItem extends Equatable { /// Whether this item is mandatory for onboarding. final bool isMandatory; - /// Creates an [AttireItem]. - const AttireItem({ - required this.id, - required this.label, - this.iconName, - this.imageUrl, - this.isMandatory = false, - }); + /// The current verification status of the uploaded photo. + final AttireVerificationStatus? verificationStatus; + + /// The URL of the photo uploaded by the staff member. + final String? photoUrl; + + /// The ID of the verification record. + final String? verificationId; @override - List get props => [id, label, iconName, imageUrl, isMandatory]; + List get props => [ + id, + code, + label, + description, + imageUrl, + isMandatory, + verificationStatus, + photoUrl, + verificationId, + ]; + + /// Creates a copy of this [AttireItem] with the given fields replaced. + AttireItem copyWith({ + String? id, + String? code, + String? label, + String? description, + String? imageUrl, + bool? isMandatory, + AttireVerificationStatus? verificationStatus, + String? photoUrl, + String? verificationId, + }) { + return AttireItem( + id: id ?? this.id, + code: code ?? this.code, + label: label ?? this.label, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + isMandatory: isMandatory ?? this.isMandatory, + verificationStatus: verificationStatus ?? this.verificationStatus, + photoUrl: photoUrl ?? this.photoUrl, + verificationId: verificationId ?? this.verificationId, + ); + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart new file mode 100644 index 00000000..f766e8dc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart @@ -0,0 +1,39 @@ +/// Represents the verification status of an attire item photo. +enum AttireVerificationStatus { + /// Job is created and waiting to be processed. + pending('PENDING'), + + /// Job is currently being processed by machine or human. + processing('PROCESSING'), + + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const AttireVerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [AttireVerificationStatus] from a string. + static AttireVerificationStatus fromString(String value) { + return AttireVerificationStatus.values.firstWhere( + (AttireVerificationStatus e) => e.value == value, + orElse: () => AttireVerificationStatus.error, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart b/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart index ab8914fa..05676b23 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart @@ -22,7 +22,7 @@ enum ExperienceSkill { static ExperienceSkill? fromString(String value) { try { - return ExperienceSkill.values.firstWhere((e) => e.value == value); + return ExperienceSkill.values.firstWhere((ExperienceSkill e) => e.value == value); } catch (_) { return null; } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart b/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart index 1295ff71..f0de201e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart @@ -13,7 +13,7 @@ enum Industry { static Industry? fromString(String value) { try { - return Industry.values.firstWhere((e) => e.value == value); + return Industry.values.firstWhere((Industry e) => e.value == value); } catch (_) { return null; } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart index 7df6a2a3..01305436 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart @@ -11,6 +11,17 @@ enum DocumentStatus { /// Represents a staff compliance document. class StaffDocument extends Equatable { + + const StaffDocument({ + required this.id, + required this.staffId, + required this.documentId, + required this.name, + this.description, + required this.status, + this.documentUrl, + this.expiryDate, + }); /// The unique identifier of the staff document record. final String id; @@ -35,19 +46,8 @@ class StaffDocument extends Equatable { /// The expiry date of the document. final DateTime? expiryDate; - const StaffDocument({ - required this.id, - required this.staffId, - required this.documentId, - required this.name, - this.description, - required this.status, - this.documentUrl, - this.expiryDate, - }); - @override - List get props => [ + List get props => [ id, staffId, documentId, diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart b/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart index bdb07d7b..bc3967b1 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart @@ -5,6 +5,18 @@ enum TaxFormType { i9, w4 } enum TaxFormStatus { notStarted, inProgress, submitted, approved, rejected } abstract class TaxForm extends Equatable { + + const TaxForm({ + required this.id, + required this.title, + this.subtitle, + this.description, + this.status = TaxFormStatus.notStarted, + this.staffId, + this.formData = const {}, + this.createdAt, + this.updatedAt, + }); final String id; TaxFormType get type; final String title; @@ -16,20 +28,8 @@ abstract class TaxForm extends Equatable { final DateTime? createdAt; final DateTime? updatedAt; - const TaxForm({ - required this.id, - required this.title, - this.subtitle, - this.description, - this.status = TaxFormStatus.notStarted, - this.staffId, - this.formData = const {}, - this.createdAt, - this.updatedAt, - }); - @override - List get props => [ + List get props => [ id, type, title, diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart new file mode 100644 index 00000000..0a4db09b --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart @@ -0,0 +1,37 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class CoverageReport extends Equatable { + + const CoverageReport({ + required this.overallCoverage, + required this.totalNeeded, + required this.totalFilled, + required this.dailyCoverage, + }); + final double overallCoverage; + final int totalNeeded; + final int totalFilled; + final List dailyCoverage; + + @override + List get props => [overallCoverage, totalNeeded, totalFilled, dailyCoverage]; +} + +class CoverageDay extends Equatable { + + const CoverageDay({ + required this.date, + required this.needed, + required this.filled, + required this.percentage, + }); + final DateTime date; + final int needed; + final int filled; + final double percentage; + + @override + List get props => [date, needed, filled, percentage]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart new file mode 100644 index 00000000..47d01056 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart @@ -0,0 +1,65 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class DailyOpsReport extends Equatable { + + const DailyOpsReport({ + required this.scheduledShifts, + required this.workersConfirmed, + required this.inProgressShifts, + required this.completedShifts, + required this.shifts, + }); + final int scheduledShifts; + final int workersConfirmed; + final int inProgressShifts; + final int completedShifts; + final List shifts; + + @override + List get props => [ + scheduledShifts, + workersConfirmed, + inProgressShifts, + completedShifts, + shifts, + ]; +} + +class DailyOpsShift extends Equatable { + + const DailyOpsShift({ + required this.id, + required this.title, + required this.location, + required this.startTime, + required this.endTime, + required this.workersNeeded, + required this.filled, + required this.status, + this.hourlyRate, + }); + final String id; + final String title; + final String location; + final DateTime startTime; + final DateTime endTime; + final int workersNeeded; + final int filled; + final String status; + final double? hourlyRate; + + @override + List get props => [ + id, + title, + location, + startTime, + endTime, + workersNeeded, + filled, + status, + hourlyRate, + ]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart new file mode 100644 index 00000000..c4c14568 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart @@ -0,0 +1,79 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class ForecastReport extends Equatable { + + const ForecastReport({ + required this.projectedSpend, + required this.projectedWorkers, + required this.averageLaborCost, + required this.chartData, + this.totalShifts = 0, + this.totalHours = 0.0, + this.avgWeeklySpend = 0.0, + this.weeklyBreakdown = const [], + }); + final double projectedSpend; + final int projectedWorkers; + final double averageLaborCost; + final List chartData; + + // New fields for the updated design + final int totalShifts; + final double totalHours; + final double avgWeeklySpend; + final List weeklyBreakdown; + + @override + List get props => [ + projectedSpend, + projectedWorkers, + averageLaborCost, + chartData, + totalShifts, + totalHours, + avgWeeklySpend, + weeklyBreakdown, + ]; +} + +class ForecastPoint extends Equatable { + + const ForecastPoint({ + required this.date, + required this.projectedCost, + required this.workersNeeded, + }); + final DateTime date; + final double projectedCost; + final int workersNeeded; + + @override + List get props => [date, projectedCost, workersNeeded]; +} + +class ForecastWeek extends Equatable { + + const ForecastWeek({ + required this.weekNumber, + required this.totalCost, + required this.shiftsCount, + required this.hoursCount, + required this.avgCostPerShift, + }); + final int weekNumber; + final double totalCost; + final int shiftsCount; + final double hoursCount; + final double avgCostPerShift; + + @override + List get props => [ + weekNumber, + totalCost, + shiftsCount, + hoursCount, + avgCostPerShift, + ]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart new file mode 100644 index 00000000..5e6f9fe7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart @@ -0,0 +1,35 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class NoShowReport extends Equatable { + + const NoShowReport({ + required this.totalNoShows, + required this.noShowRate, + required this.flaggedWorkers, + }); + final int totalNoShows; + final double noShowRate; + final List flaggedWorkers; + + @override + List get props => [totalNoShows, noShowRate, flaggedWorkers]; +} + +class NoShowWorker extends Equatable { + + const NoShowWorker({ + required this.id, + required this.fullName, + required this.noShowCount, + required this.reliabilityScore, + }); + final String id; + final String fullName; + final int noShowCount; + final double reliabilityScore; + + @override + List get props => [id, fullName, noShowCount, reliabilityScore]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart new file mode 100644 index 00000000..51bb79b5 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart @@ -0,0 +1,37 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class PerformanceReport extends Equatable { + + const PerformanceReport({ + required this.fillRate, + required this.completionRate, + required this.onTimeRate, + required this.avgFillTimeHours, + required this.keyPerformanceIndicators, + }); + final double fillRate; + final double completionRate; + final double onTimeRate; + final double avgFillTimeHours; // in hours + final List keyPerformanceIndicators; + + @override + List get props => [fillRate, completionRate, onTimeRate, avgFillTimeHours, keyPerformanceIndicators]; +} + +class PerformanceMetric extends Equatable { // e.g. 0.05 for +5% + + const PerformanceMetric({ + required this.label, + required this.value, + required this.trend, + }); + final String label; + final String value; + final double trend; + + @override + List get props => [label, value, trend]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart b/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart new file mode 100644 index 00000000..0fb635e5 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart @@ -0,0 +1,31 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class ReportsSummary extends Equatable { + + const ReportsSummary({ + required this.totalHours, + required this.otHours, + required this.totalSpend, + required this.fillRate, + required this.avgFillTimeHours, + required this.noShowRate, + }); + final double totalHours; + final double otHours; + final double totalSpend; + final double fillRate; + final double avgFillTimeHours; + final double noShowRate; + + @override + List get props => [ + totalHours, + otHours, + totalSpend, + fillRate, + avgFillTimeHours, + noShowRate, + ]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart new file mode 100644 index 00000000..8594fe96 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart @@ -0,0 +1,85 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +class SpendReport extends Equatable { + + const SpendReport({ + required this.totalSpend, + required this.averageCost, + required this.paidInvoices, + required this.pendingInvoices, + required this.overdueInvoices, + required this.invoices, + required this.chartData, + required this.industryBreakdown, + }); + final double totalSpend; + final double averageCost; + final int paidInvoices; + final int pendingInvoices; + final int overdueInvoices; + final List invoices; + final List chartData; + final List industryBreakdown; + + @override + List get props => [ + totalSpend, + averageCost, + paidInvoices, + pendingInvoices, + overdueInvoices, + invoices, + chartData, + industryBreakdown, + ]; +} + +class SpendIndustryCategory extends Equatable { + + const SpendIndustryCategory({ + required this.name, + required this.amount, + required this.percentage, + }); + final String name; + final double amount; + final double percentage; + + @override + List get props => [name, amount, percentage]; +} + +class SpendInvoice extends Equatable { + + const SpendInvoice({ + required this.id, + required this.invoiceNumber, + required this.issueDate, + required this.amount, + required this.status, + required this.vendorName, + this.industry, + }); + final String id; + final String invoiceNumber; + final DateTime issueDate; + final double amount; + final String status; + final String vendorName; + final String? industry; + + @override + List get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry]; +} + +class SpendChartPoint extends Equatable { + + const SpendChartPoint({required this.date, required this.amount}); + final DateTime date; + final double amount; + + @override + List get props => [date, amount]; +} + diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index e24d6477..a6d6fdeb 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -2,36 +2,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/shifts/break/break.dart'; class Shift extends Equatable { - final String id; - final String title; - final String clientName; - final String? logoUrl; - final double hourlyRate; - final String location; - final String locationAddress; - final String date; - final String startTime; - final String endTime; - final String createdDate; - final bool? tipsAvailable; - final bool? travelTime; - final bool? mealProvided; - final bool? parkingAvailable; - final bool? gasCompensation; - final String? description; - final String? instructions; - final List? managers; - final double? latitude; - final double? longitude; - final String? status; - final int? durationDays; // For multi-day shifts - final int? requiredSlots; - final int? filledSlots; - final String? roleId; - final bool? hasApplied; - final double? totalValue; - final Break? breakInfo; - const Shift({ required this.id, required this.title, @@ -62,8 +32,52 @@ class Shift extends Equatable { this.hasApplied, this.totalValue, this.breakInfo, + this.orderId, + this.orderType, + this.startDate, + this.endDate, + this.recurringDays, + this.permanentDays, + this.schedules, }); + final String id; + final String title; + final String clientName; + final String? logoUrl; + final double hourlyRate; + final String location; + final String locationAddress; + final String date; + final String startTime; + final String endTime; + final String createdDate; + final bool? tipsAvailable; + final bool? travelTime; + final bool? mealProvided; + final bool? parkingAvailable; + final bool? gasCompensation; + final String? description; + final String? instructions; + final List? managers; + final double? latitude; + final double? longitude; + final String? status; + final int? durationDays; // For multi-day shifts + final int? requiredSlots; + final int? filledSlots; + final String? roleId; + final bool? hasApplied; + final double? totalValue; + final Break? breakInfo; + final String? orderId; + final String? orderType; + final String? startDate; + final String? endDate; + final List? recurringDays; + final List? permanentDays; + final List? schedules; + @override List get props => [ id, @@ -95,9 +109,31 @@ class Shift extends Equatable { hasApplied, totalValue, breakInfo, + orderId, + orderType, + startDate, + endDate, + recurringDays, + permanentDays, + schedules, ]; } +class ShiftSchedule extends Equatable { + const ShiftSchedule({ + required this.date, + required this.startTime, + required this.endTime, + }); + + final String date; + final String startTime; + final String endTime; + + @override + List get props => [date, startTime, endTime]; +} + class ShiftManager extends Equatable { const ShiftManager({required this.name, required this.phone, this.avatar}); diff --git a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart index c9effdd4..a70e2bf6 100644 --- a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart +++ b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart @@ -32,8 +32,8 @@ sealed class AuthException extends AppException { /// Thrown when email/password combination is incorrect. class InvalidCredentialsException extends AuthException { - const InvalidCredentialsException({String? technicalMessage}) - : super(code: 'AUTH_001', technicalMessage: technicalMessage); + const InvalidCredentialsException({super.technicalMessage}) + : super(code: 'AUTH_001'); @override String get messageKey => 'errors.auth.invalid_credentials'; @@ -41,8 +41,8 @@ class InvalidCredentialsException extends AuthException { /// Thrown when attempting to register with an email that already exists. class AccountExistsException extends AuthException { - const AccountExistsException({String? technicalMessage}) - : super(code: 'AUTH_002', technicalMessage: technicalMessage); + const AccountExistsException({super.technicalMessage}) + : super(code: 'AUTH_002'); @override String get messageKey => 'errors.auth.account_exists'; @@ -50,8 +50,8 @@ class AccountExistsException extends AuthException { /// Thrown when the user session has expired. class SessionExpiredException extends AuthException { - const SessionExpiredException({String? technicalMessage}) - : super(code: 'AUTH_003', technicalMessage: technicalMessage); + const SessionExpiredException({super.technicalMessage}) + : super(code: 'AUTH_003'); @override String get messageKey => 'errors.auth.session_expired'; @@ -59,8 +59,8 @@ class SessionExpiredException extends AuthException { /// Thrown when user profile is not found in database after Firebase auth. class UserNotFoundException extends AuthException { - const UserNotFoundException({String? technicalMessage}) - : super(code: 'AUTH_004', technicalMessage: technicalMessage); + const UserNotFoundException({super.technicalMessage}) + : super(code: 'AUTH_004'); @override String get messageKey => 'errors.auth.user_not_found'; @@ -68,8 +68,8 @@ class UserNotFoundException extends AuthException { /// Thrown when user is not authorized for the current app (wrong role). class UnauthorizedAppException extends AuthException { - const UnauthorizedAppException({String? technicalMessage}) - : super(code: 'AUTH_005', technicalMessage: technicalMessage); + const UnauthorizedAppException({super.technicalMessage}) + : super(code: 'AUTH_005'); @override String get messageKey => 'errors.auth.unauthorized_app'; @@ -77,8 +77,8 @@ class UnauthorizedAppException extends AuthException { /// Thrown when password doesn't meet security requirements. class WeakPasswordException extends AuthException { - const WeakPasswordException({String? technicalMessage}) - : super(code: 'AUTH_006', technicalMessage: technicalMessage); + const WeakPasswordException({super.technicalMessage}) + : super(code: 'AUTH_006'); @override String get messageKey => 'errors.auth.weak_password'; @@ -86,8 +86,8 @@ class WeakPasswordException extends AuthException { /// Thrown when sign-up process fails. class SignUpFailedException extends AuthException { - const SignUpFailedException({String? technicalMessage}) - : super(code: 'AUTH_007', technicalMessage: technicalMessage); + const SignUpFailedException({super.technicalMessage}) + : super(code: 'AUTH_007'); @override String get messageKey => 'errors.auth.sign_up_failed'; @@ -95,8 +95,8 @@ class SignUpFailedException extends AuthException { /// Thrown when sign-in process fails. class SignInFailedException extends AuthException { - const SignInFailedException({String? technicalMessage}) - : super(code: 'AUTH_008', technicalMessage: technicalMessage); + const SignInFailedException({super.technicalMessage}) + : super(code: 'AUTH_008'); @override String get messageKey => 'errors.auth.sign_in_failed'; @@ -104,8 +104,8 @@ class SignInFailedException extends AuthException { /// Thrown when email exists but password doesn't match. class PasswordMismatchException extends AuthException { - const PasswordMismatchException({String? technicalMessage}) - : super(code: 'AUTH_009', technicalMessage: technicalMessage); + const PasswordMismatchException({super.technicalMessage}) + : super(code: 'AUTH_009'); @override String get messageKey => 'errors.auth.password_mismatch'; @@ -113,8 +113,8 @@ class PasswordMismatchException extends AuthException { /// Thrown when account exists only with Google provider (no password). class GoogleOnlyAccountException extends AuthException { - const GoogleOnlyAccountException({String? technicalMessage}) - : super(code: 'AUTH_010', technicalMessage: technicalMessage); + const GoogleOnlyAccountException({super.technicalMessage}) + : super(code: 'AUTH_010'); @override String get messageKey => 'errors.auth.google_only_account'; @@ -131,8 +131,8 @@ sealed class HubException extends AppException { /// Thrown when attempting to delete a hub that has active orders. class HubHasOrdersException extends HubException { - const HubHasOrdersException({String? technicalMessage}) - : super(code: 'HUB_001', technicalMessage: technicalMessage); + const HubHasOrdersException({super.technicalMessage}) + : super(code: 'HUB_001'); @override String get messageKey => 'errors.hub.has_orders'; @@ -140,8 +140,8 @@ class HubHasOrdersException extends HubException { /// Thrown when hub is not found. class HubNotFoundException extends HubException { - const HubNotFoundException({String? technicalMessage}) - : super(code: 'HUB_002', technicalMessage: technicalMessage); + const HubNotFoundException({super.technicalMessage}) + : super(code: 'HUB_002'); @override String get messageKey => 'errors.hub.not_found'; @@ -149,8 +149,8 @@ class HubNotFoundException extends HubException { /// Thrown when hub creation fails. class HubCreationFailedException extends HubException { - const HubCreationFailedException({String? technicalMessage}) - : super(code: 'HUB_003', technicalMessage: technicalMessage); + const HubCreationFailedException({super.technicalMessage}) + : super(code: 'HUB_003'); @override String get messageKey => 'errors.hub.creation_failed'; @@ -167,8 +167,8 @@ sealed class OrderException extends AppException { /// Thrown when order creation is attempted without a hub. class OrderMissingHubException extends OrderException { - const OrderMissingHubException({String? technicalMessage}) - : super(code: 'ORDER_001', technicalMessage: technicalMessage); + const OrderMissingHubException({super.technicalMessage}) + : super(code: 'ORDER_001'); @override String get messageKey => 'errors.order.missing_hub'; @@ -176,8 +176,8 @@ class OrderMissingHubException extends OrderException { /// Thrown when order creation is attempted without a vendor. class OrderMissingVendorException extends OrderException { - const OrderMissingVendorException({String? technicalMessage}) - : super(code: 'ORDER_002', technicalMessage: technicalMessage); + const OrderMissingVendorException({super.technicalMessage}) + : super(code: 'ORDER_002'); @override String get messageKey => 'errors.order.missing_vendor'; @@ -185,8 +185,8 @@ class OrderMissingVendorException extends OrderException { /// Thrown when order creation fails. class OrderCreationFailedException extends OrderException { - const OrderCreationFailedException({String? technicalMessage}) - : super(code: 'ORDER_003', technicalMessage: technicalMessage); + const OrderCreationFailedException({super.technicalMessage}) + : super(code: 'ORDER_003'); @override String get messageKey => 'errors.order.creation_failed'; @@ -194,8 +194,8 @@ class OrderCreationFailedException extends OrderException { /// Thrown when shift creation fails. class ShiftCreationFailedException extends OrderException { - const ShiftCreationFailedException({String? technicalMessage}) - : super(code: 'ORDER_004', technicalMessage: technicalMessage); + const ShiftCreationFailedException({super.technicalMessage}) + : super(code: 'ORDER_004'); @override String get messageKey => 'errors.order.shift_creation_failed'; @@ -203,8 +203,8 @@ class ShiftCreationFailedException extends OrderException { /// Thrown when order is missing required business context. class OrderMissingBusinessException extends OrderException { - const OrderMissingBusinessException({String? technicalMessage}) - : super(code: 'ORDER_005', technicalMessage: technicalMessage); + const OrderMissingBusinessException({super.technicalMessage}) + : super(code: 'ORDER_005'); @override String get messageKey => 'errors.order.missing_business'; @@ -221,8 +221,8 @@ sealed class ProfileException extends AppException { /// Thrown when staff profile is not found. class StaffProfileNotFoundException extends ProfileException { - const StaffProfileNotFoundException({String? technicalMessage}) - : super(code: 'PROFILE_001', technicalMessage: technicalMessage); + const StaffProfileNotFoundException({super.technicalMessage}) + : super(code: 'PROFILE_001'); @override String get messageKey => 'errors.profile.staff_not_found'; @@ -230,8 +230,8 @@ class StaffProfileNotFoundException extends ProfileException { /// Thrown when business profile is not found. class BusinessNotFoundException extends ProfileException { - const BusinessNotFoundException({String? technicalMessage}) - : super(code: 'PROFILE_002', technicalMessage: technicalMessage); + const BusinessNotFoundException({super.technicalMessage}) + : super(code: 'PROFILE_002'); @override String get messageKey => 'errors.profile.business_not_found'; @@ -239,8 +239,8 @@ class BusinessNotFoundException extends ProfileException { /// Thrown when profile update fails. class ProfileUpdateFailedException extends ProfileException { - const ProfileUpdateFailedException({String? technicalMessage}) - : super(code: 'PROFILE_003', technicalMessage: technicalMessage); + const ProfileUpdateFailedException({super.technicalMessage}) + : super(code: 'PROFILE_003'); @override String get messageKey => 'errors.profile.update_failed'; @@ -257,8 +257,8 @@ sealed class ShiftException extends AppException { /// Thrown when no open roles are available for a shift. class NoOpenRolesException extends ShiftException { - const NoOpenRolesException({String? technicalMessage}) - : super(code: 'SHIFT_001', technicalMessage: technicalMessage); + const NoOpenRolesException({super.technicalMessage}) + : super(code: 'SHIFT_001'); @override String get messageKey => 'errors.shift.no_open_roles'; @@ -266,8 +266,8 @@ class NoOpenRolesException extends ShiftException { /// Thrown when application for shift is not found. class ApplicationNotFoundException extends ShiftException { - const ApplicationNotFoundException({String? technicalMessage}) - : super(code: 'SHIFT_002', technicalMessage: technicalMessage); + const ApplicationNotFoundException({super.technicalMessage}) + : super(code: 'SHIFT_002'); @override String get messageKey => 'errors.shift.application_not_found'; @@ -275,8 +275,8 @@ class ApplicationNotFoundException extends ShiftException { /// Thrown when no active shift is found for clock out. class NoActiveShiftException extends ShiftException { - const NoActiveShiftException({String? technicalMessage}) - : super(code: 'SHIFT_003', technicalMessage: technicalMessage); + const NoActiveShiftException({super.technicalMessage}) + : super(code: 'SHIFT_003'); @override String get messageKey => 'errors.shift.no_active_shift'; @@ -288,8 +288,8 @@ class NoActiveShiftException extends ShiftException { /// Thrown when there is no network connection. class NetworkException extends AppException { - const NetworkException({String? technicalMessage}) - : super(code: 'NET_001', technicalMessage: technicalMessage); + const NetworkException({super.technicalMessage}) + : super(code: 'NET_001'); @override String get messageKey => 'errors.generic.no_connection'; @@ -297,8 +297,8 @@ class NetworkException extends AppException { /// Thrown when an unexpected error occurs. class UnknownException extends AppException { - const UnknownException({String? technicalMessage}) - : super(code: 'UNKNOWN', technicalMessage: technicalMessage); + const UnknownException({super.technicalMessage}) + : super(code: 'UNKNOWN'); @override String get messageKey => 'errors.generic.unknown'; @@ -306,8 +306,8 @@ class UnknownException extends AppException { /// Thrown when the server returns an error (500, etc.). class ServerException extends AppException { - const ServerException({String? technicalMessage}) - : super(code: 'SRV_001', technicalMessage: technicalMessage); + const ServerException({super.technicalMessage}) + : super(code: 'SRV_001'); @override String get messageKey => 'errors.generic.server_error'; @@ -315,8 +315,8 @@ class ServerException extends AppException { /// Thrown when the service is unavailable (Data Connect down). class ServiceUnavailableException extends AppException { - const ServiceUnavailableException({String? technicalMessage}) - : super(code: 'SRV_002', technicalMessage: technicalMessage); + const ServiceUnavailableException({super.technicalMessage}) + : super(code: 'SRV_002'); @override String get messageKey => 'errors.generic.service_unavailable'; @@ -324,8 +324,8 @@ class ServiceUnavailableException extends AppException { /// Thrown when user is not authenticated. class NotAuthenticatedException extends AppException { - const NotAuthenticatedException({String? technicalMessage}) - : super(code: 'AUTH_NOT_LOGGED', technicalMessage: technicalMessage); + const NotAuthenticatedException({super.technicalMessage}) + : super(code: 'AUTH_NOT_LOGGED'); @override String get messageKey => 'errors.auth.not_authenticated'; diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/blocs/client_auth_bloc.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/blocs/client_auth_bloc.dart index 4d98faef..4b799c7d 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/blocs/client_auth_bloc.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/blocs/client_auth_bloc.dart @@ -55,7 +55,7 @@ class ClientAuthBloc extends Bloc emit(state.copyWith(status: ClientAuthStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final User user = await _signInWithEmail( SignInWithEmailArguments(email: event.email, password: event.password), @@ -77,7 +77,7 @@ class ClientAuthBloc extends Bloc emit(state.copyWith(status: ClientAuthStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final User user = await _signUpWithEmail( SignUpWithEmailArguments( @@ -103,7 +103,7 @@ class ClientAuthBloc extends Bloc emit(state.copyWith(status: ClientAuthStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final User user = await _signInWithSocial( SignInWithSocialArguments(provider: event.provider), @@ -125,7 +125,7 @@ class ClientAuthBloc extends Bloc emit(state.copyWith(status: ClientAuthStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { await _signOut(); emit(state.copyWith(status: ClientAuthStatus.signedOut, user: null)); diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 1acdc69b..3a594e44 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -9,8 +9,14 @@ import 'domain/usecases/get_invoice_history.dart'; import 'domain/usecases/get_pending_invoices.dart'; import 'domain/usecases/get_savings_amount.dart'; import 'domain/usecases/get_spending_breakdown.dart'; +import 'domain/usecases/approve_invoice.dart'; +import 'domain/usecases/dispute_invoice.dart'; import 'presentation/blocs/billing_bloc.dart'; +import 'presentation/models/billing_invoice_model.dart'; import 'presentation/pages/billing_page.dart'; +import 'presentation/pages/completion_review_page.dart'; +import 'presentation/pages/invoice_ready_page.dart'; +import 'presentation/pages/pending_invoices_page.dart'; /// Modular module for the billing feature. class BillingModule extends Module { @@ -28,6 +34,8 @@ class BillingModule extends Module { i.addSingleton(GetPendingInvoicesUseCase.new); i.addSingleton(GetInvoiceHistoryUseCase.new); i.addSingleton(GetSpendingBreakdownUseCase.new); + i.addSingleton(ApproveInvoiceUseCase.new); + i.addSingleton(DisputeInvoiceUseCase.new); // BLoCs i.addSingleton( @@ -38,6 +46,8 @@ class BillingModule extends Module { getPendingInvoices: i.get(), getInvoiceHistory: i.get(), getSpendingBreakdown: i.get(), + approveInvoice: i.get(), + disputeInvoice: i.get(), ), ); } @@ -45,5 +55,8 @@ class BillingModule extends Module { @override void routes(RouteManager r) { r.child(ClientPaths.childRoute(ClientPaths.billing, ClientPaths.billing), child: (_) => const BillingPage()); + r.child('/completion-review', child: (_) => ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?)); + r.child('/invoice-ready', child: (_) => const InvoiceReadyPage()); + r.child('/awaiting-approval', child: (_) => const PendingInvoicesPage()); } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 95578127..387263ac 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -1,261 +1,70 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:krow_data_connect/krow_data_connect.dart' as data_connect; +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/models/billing_period.dart'; import '../../domain/repositories/billing_repository.dart'; -/// Implementation of [BillingRepository] in the Data layer. +/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository]. /// -/// This class is responsible for retrieving billing data from the -/// Data Connect layer and mapping it to Domain entities. +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class BillingRepositoryImpl implements BillingRepository { - /// Creates a [BillingRepositoryImpl]. + BillingRepositoryImpl({ - data_connect.DataConnectService? service, - }) : _service = service ?? data_connect.DataConnectService.instance; + dc.BillingConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getBillingRepository(), + _service = service ?? dc.DataConnectService.instance; + final dc.BillingConnectorRepository _connectorRepository; + final dc.DataConnectService _service; - final data_connect.DataConnectService _service; - - /// Fetches bank accounts associated with the business. @override Future> getBankAccounts() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult< - data_connect.GetAccountsByOwnerIdData, - data_connect.GetAccountsByOwnerIdVariables> result = - await _service.connector - .getAccountsByOwnerId(ownerId: businessId) - .execute(); - - return result.data.accounts.map(_mapBankAccount).toList(); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getBankAccounts(businessId: businessId); } - /// Fetches the current bill amount by aggregating open invoices. @override Future getCurrentBillAmount() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult result = - await _service.connector - .listInvoicesByBusinessId(businessId: businessId) - .execute(); - - return result.data.invoices - .map(_mapInvoice) - .where((Invoice i) => i.status == InvoiceStatus.open) - .fold( - 0.0, - (double sum, Invoice item) => sum + item.totalAmount, - ); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getCurrentBillAmount(businessId: businessId); } - /// Fetches the history of paid invoices. @override Future> getInvoiceHistory() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult result = - await _service.connector - .listInvoicesByBusinessId( - businessId: businessId, - ) - .limit(10) - .execute(); - - return result.data.invoices.map(_mapInvoice).toList(); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getInvoiceHistory(businessId: businessId); } - /// Fetches pending invoices (Open or Disputed). @override Future> getPendingInvoices() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult result = - await _service.connector - .listInvoicesByBusinessId(businessId: businessId) - .execute(); - - return result.data.invoices - .map(_mapInvoice) - .where( - (Invoice i) => - i.status == InvoiceStatus.open || - i.status == InvoiceStatus.disputed, - ) - .toList(); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getPendingInvoices(businessId: businessId); } - /// Fetches the estimated savings amount. @override Future getSavingsAmount() async { - // Simulating savings calculation (e.g., comparing to market rates). - await Future.delayed(const Duration(milliseconds: 0)); + // Simulating savings calculation return 0.0; } - /// Fetches the breakdown of spending. @override Future> getSpendingBreakdown(BillingPeriod period) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final DateTime now = DateTime.now(); - final DateTime start; - final DateTime end; - if (period == BillingPeriod.week) { - final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = DateTime( - now.year, - now.month, - now.day, - ).subtract(Duration(days: daysFromMonday)); - start = DateTime(monday.year, monday.month, monday.day); - end = DateTime( - monday.year, monday.month, monday.day + 6, 23, 59, 59, 999); - } else { - start = DateTime(now.year, now.month, 1); - end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999); - } - - final fdc.QueryResult< - data_connect.ListShiftRolesByBusinessAndDatesSummaryData, - data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables> - result = await _service.connector - .listShiftRolesByBusinessAndDatesSummary( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); - - final List - shiftRoles = result.data.shiftRoles; - if (shiftRoles.isEmpty) { - return []; - } - - final Map summary = {}; - for (final data_connect - .ListShiftRolesByBusinessAndDatesSummaryShiftRoles role - in shiftRoles) { - final String roleId = role.roleId; - final String roleName = role.role.name; - final double hours = role.hours ?? 0.0; - final double totalValue = role.totalValue ?? 0.0; - final _RoleSummary? existing = summary[roleId]; - if (existing == null) { - summary[roleId] = _RoleSummary( - roleId: roleId, - roleName: roleName, - totalHours: hours, - totalValue: totalValue, - ); - } else { - summary[roleId] = existing.copyWith( - totalHours: existing.totalHours + hours, - totalValue: existing.totalValue + totalValue, - ); - } - } - - return summary.values - .map( - (_RoleSummary item) => InvoiceItem( - id: item.roleId, - invoiceId: item.roleId, - staffId: item.roleName, - workHours: item.totalHours, - rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, - amount: item.totalValue, - ), - ) - .toList(); - }); - } - - Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) { - return Invoice( - id: invoice.id, - eventId: invoice.orderId, - businessId: invoice.businessId, - status: _mapInvoiceStatus(invoice.status), - totalAmount: invoice.amount, - workAmount: invoice.amount, - addonsAmount: invoice.otherCharges ?? 0, - invoiceNumber: invoice.invoiceNumber, - issueDate: _service.toDateTime(invoice.issueDate)!, + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getSpendingBreakdown( + businessId: businessId, + period: period, ); } - BusinessBankAccount _mapBankAccount( - data_connect.GetAccountsByOwnerIdAccounts account, - ) { - return BusinessBankAccountAdapter.fromPrimitives( - id: account.id, - bank: account.bank, - last4: account.last4, - isPrimary: account.isPrimary ?? false, - expiryTime: _service.toDateTime(account.expiryTime), - ); + @override + Future approveInvoice(String id) async { + return _connectorRepository.approveInvoice(id: id); } - InvoiceStatus _mapInvoiceStatus( - data_connect.EnumValue status, - ) { - if (status is data_connect.Known) { - switch (status.value) { - case data_connect.InvoiceStatus.PAID: - return InvoiceStatus.paid; - case data_connect.InvoiceStatus.OVERDUE: - return InvoiceStatus.overdue; - case data_connect.InvoiceStatus.DISPUTED: - return InvoiceStatus.disputed; - case data_connect.InvoiceStatus.APPROVED: - return InvoiceStatus.verified; - case data_connect.InvoiceStatus.PENDING_REVIEW: - case data_connect.InvoiceStatus.PENDING: - case data_connect.InvoiceStatus.DRAFT: - return InvoiceStatus.open; - } - } - return InvoiceStatus.open; + @override + Future disputeInvoice(String id, String reason) async { + return _connectorRepository.disputeInvoice(id: id, reason: reason); } } -class _RoleSummary { - const _RoleSummary({ - required this.roleId, - required this.roleName, - required this.totalHours, - required this.totalValue, - }); - - final String roleId; - final String roleName; - final double totalHours; - final double totalValue; - - _RoleSummary copyWith({ - double? totalHours, - double? totalValue, - }) { - return _RoleSummary( - roleId: roleId, - roleName: roleName, - totalHours: totalHours ?? this.totalHours, - totalValue: totalValue ?? this.totalValue, - ); - } -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart deleted file mode 100644 index a3ea057b..00000000 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart +++ /dev/null @@ -1,4 +0,0 @@ -enum BillingPeriod { - week, - month, -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart index d631a40b..2041c0d2 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart @@ -1,5 +1,4 @@ import 'package:krow_domain/krow_domain.dart'; -import '../models/billing_period.dart'; /// Repository interface for billing related operations. /// @@ -24,4 +23,10 @@ abstract class BillingRepository { /// Fetches invoice items for spending breakdown analysis. Future> getSpendingBreakdown(BillingPeriod period); + + /// Approves an invoice. + Future approveInvoice(String id); + + /// Disputes an invoice. + Future disputeInvoice(String id, String reason); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart new file mode 100644 index 00000000..648c9986 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart @@ -0,0 +1,13 @@ +import 'package:krow_core/core.dart'; +import '../repositories/billing_repository.dart'; + +/// Use case for approving an invoice. +class ApproveInvoiceUseCase extends UseCase { + /// Creates an [ApproveInvoiceUseCase]. + ApproveInvoiceUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future call(String input) => _repository.approveInvoice(input); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart new file mode 100644 index 00000000..7d05deb6 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; +import '../repositories/billing_repository.dart'; + +/// Params for [DisputeInvoiceUseCase]. +class DisputeInvoiceParams { + const DisputeInvoiceParams({required this.id, required this.reason}); + final String id; + final String reason; +} + +/// Use case for disputing an invoice. +class DisputeInvoiceUseCase extends UseCase { + /// Creates a [DisputeInvoiceUseCase]. + DisputeInvoiceUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future call(DisputeInvoiceParams input) => + _repository.disputeInvoice(input.id, input.reason); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart index 09193e70..69e4c34b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart @@ -1,6 +1,5 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../models/billing_period.dart'; import '../repositories/billing_repository.dart'; /// Use case for fetching the spending breakdown items. diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index ee88ed63..0206a3b9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../domain/usecases/get_bank_accounts.dart'; @@ -7,6 +8,8 @@ import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_pending_invoices.dart'; import '../../domain/usecases/get_savings_amount.dart'; import '../../domain/usecases/get_spending_breakdown.dart'; +import '../../domain/usecases/approve_invoice.dart'; +import '../../domain/usecases/dispute_invoice.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; import 'billing_event.dart'; @@ -23,15 +26,21 @@ class BillingBloc extends Bloc required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, required GetSpendingBreakdownUseCase getSpendingBreakdown, + required ApproveInvoiceUseCase approveInvoice, + required DisputeInvoiceUseCase disputeInvoice, }) : _getBankAccounts = getBankAccounts, _getCurrentBillAmount = getCurrentBillAmount, _getSavingsAmount = getSavingsAmount, _getPendingInvoices = getPendingInvoices, _getInvoiceHistory = getInvoiceHistory, _getSpendingBreakdown = getSpendingBreakdown, + _approveInvoice = approveInvoice, + _disputeInvoice = disputeInvoice, super(const BillingState()) { on(_onLoadStarted); on(_onPeriodChanged); + on(_onInvoiceApproved); + on(_onInvoiceDisputed); } final GetBankAccountsUseCase _getBankAccounts; @@ -40,6 +49,8 @@ class BillingBloc extends Bloc final GetPendingInvoicesUseCase _getPendingInvoices; final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetSpendingBreakdownUseCase _getSpendingBreakdown; + final ApproveInvoiceUseCase _approveInvoice; + final DisputeInvoiceUseCase _disputeInvoice; Future _onLoadStarted( BillingLoadStarted event, @@ -47,7 +58,7 @@ class BillingBloc extends Bloc ) async { emit(state.copyWith(status: BillingStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final List results = await Future.wait(>[ @@ -102,7 +113,7 @@ class BillingBloc extends Bloc Emitter emit, ) async { await handleError( - emit: emit, + emit: emit.call, action: () async { final List spendingItems = await _getSpendingBreakdown.call(event.period); @@ -127,25 +138,102 @@ class BillingBloc extends Bloc ); } + Future _onInvoiceApproved( + BillingInvoiceApproved event, + Emitter 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 _onInvoiceDisputed( + BillingInvoiceDisputed event, + Emitter 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, + ), + ); + } + BillingInvoice _mapInvoiceToUiModel(Invoice invoice) { - // In a real app, fetches related Event/Business names via ID. - // For now, mapping available fields and hardcoding missing UI placeholders. - // Preserving "Existing Behavior" means we show something. + final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = invoice.issueDate == null - ? '2024-01-24' - : invoice.issueDate!.toIso8601String().split('T').first; - final String titleLabel = invoice.invoiceNumber ?? invoice.id; + ? 'N/A' + : formatter.format(invoice.issueDate!); + + final List workers = invoice.workers.map((InvoiceWorker w) { + return BillingWorkerRecord( + workerName: w.name, + roleName: w.role, + totalAmount: w.amount, + hours: w.hours, + rate: w.rate, + startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--', + endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--', + breakMinutes: w.breakMinutes, + workerAvatarUrl: w.avatarUrl, + ); + }).toList(); + + String? overallStart; + String? overallEnd; + + // Find valid times from workers instead of just taking the first one + final validStartTimes = workers + .where((w) => w.startTime != '--:--') + .map((w) => w.startTime) + .toList(); + final validEndTimes = workers + .where((w) => w.endTime != '--:--') + .map((w) => w.endTime) + .toList(); + + if (validStartTimes.isNotEmpty) { + validStartTimes.sort(); + overallStart = validStartTimes.first; + } else if (workers.isNotEmpty) { + overallStart = workers.first.startTime; + } + + if (validEndTimes.isNotEmpty) { + validEndTimes.sort(); + overallEnd = validEndTimes.last; + } else if (workers.isNotEmpty) { + overallEnd = workers.first.endTime; + } + return BillingInvoice( - id: titleLabel, - title: 'Invoice #${invoice.id}', // Placeholder as Invoice lacks title - locationAddress: - 'Location for ${invoice.eventId}', // Placeholder for address - clientName: 'Client ${invoice.businessId}', // Placeholder for client name + id: invoice.invoiceNumber ?? invoice.id, + title: invoice.title ?? 'N/A', + locationAddress: invoice.locationAddress ?? 'Remote', + clientName: invoice.clientName ?? 'N/A', date: dateLabel, totalAmount: invoice.totalAmount, - workersCount: 5, // Placeholder count - totalHours: invoice.workAmount / 25.0, // Estimating hours from amount + workersCount: invoice.staffCount ?? 0, + totalHours: invoice.totalHours ?? 0.0, status: invoice.status.name.toUpperCase(), + workers: workers, + startTime: overallStart, + endTime: overallEnd, ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart index f27060dc..929a1bf4 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../domain/models/billing_period.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Base class for all billing events. abstract class BillingEvent extends Equatable { @@ -24,3 +24,20 @@ class BillingPeriodChanged extends BillingEvent { @override List get props => [period]; } + +class BillingInvoiceApproved extends BillingEvent { + const BillingInvoiceApproved(this.invoiceId); + final String invoiceId; + + @override + List get props => [invoiceId]; +} + +class BillingInvoiceDisputed extends BillingEvent { + const BillingInvoiceDisputed(this.invoiceId, this.reason); + final String invoiceId; + final String reason; + + @override + List get props => [invoiceId, reason]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart index ef3ba019..98d8d0fd 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart @@ -1,6 +1,5 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/models/billing_period.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart index b44c7367..6e8d8e11 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart @@ -11,6 +11,9 @@ class BillingInvoice extends Equatable { required this.workersCount, required this.totalHours, required this.status, + this.workers = const [], + this.startTime, + this.endTime, }); final String id; @@ -22,6 +25,9 @@ class BillingInvoice extends Equatable { final int workersCount; final double totalHours; final String status; + final List workers; + final String? startTime; + final String? endTime; @override List get props => [ @@ -34,5 +40,45 @@ class BillingInvoice extends Equatable { workersCount, totalHours, status, + workers, + startTime, + endTime, + ]; +} + +class BillingWorkerRecord extends Equatable { + const BillingWorkerRecord({ + required this.workerName, + required this.roleName, + required this.totalAmount, + required this.hours, + required this.rate, + required this.startTime, + required this.endTime, + required this.breakMinutes, + this.workerAvatarUrl, + }); + + final String workerName; + final String roleName; + final double totalAmount; + final double hours; + final double rate; + final String startTime; + final String endTime; + final int breakMinutes; + final String? workerAvatarUrl; + + @override + List get props => [ + workerName, + roleName, + totalAmount, + hours, + rate, + startTime, + endTime, + breakMinutes, + workerAvatarUrl, ]; } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 4771b744..01d44775 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -72,6 +72,7 @@ class _BillingViewState extends State { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: UiColors.background, body: BlocConsumer( listener: (BuildContext context, BillingState state) { if (state.status == BillingStatus.failure && @@ -88,38 +89,30 @@ class _BillingViewState extends State { controller: _scrollController, slivers: [ SliverAppBar( - // ... (APP BAR CODE REMAINS UNCHANGED, BUT I MUST INCLUDE IT OR CHUNK IT CORRECTLY) - // Since I cannot see the headers in this chunk, I will target the _buildContent method instead - // to avoid messing up the whole file structure. - // Wait, I can just replace the build method wrapper. pinned: true, - expandedHeight: 200.0, + expandedHeight: 220.0, backgroundColor: UiColors.primary, + elevation: 0, + leadingWidth: 72, leading: Center( - child: UiIconButton.secondary( + child: UiIconButton( icon: UiIcons.arrowLeft, + backgroundColor: UiColors.white.withOpacity(0.15), + iconColor: UiColors.white, + useBlur: true, + size: 40, onTap: () => Modular.to.toClientHome(), ), ), - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Text( - _isScrolled - ? '\$${state.currentBill.toStringAsFixed(2)}' - : t.client_billing.title, - key: ValueKey(_isScrolled), - style: UiTypography.headline4m.copyWith( - color: UiColors.white, - ), - ), + title: Text( + t.client_billing.title, + style: UiTypography.headline3b.copyWith(color: UiColors.white), ), + centerTitle: false, flexibleSpace: FlexibleSpaceBar( background: Padding( padding: const EdgeInsets.only( - top: UiConstants.space0, - left: UiConstants.space5, - right: UiConstants.space5, - bottom: UiConstants.space10, + bottom: UiConstants.space8, ), child: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -127,21 +120,22 @@ class _BillingViewState extends State { Text( t.client_billing.current_period, style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.7), + color: UiColors.white.withOpacity(0.7), ), ), const SizedBox(height: UiConstants.space1), Text( '\$${state.currentBill.toStringAsFixed(2)}', - style: UiTypography.display1b.copyWith( + style: UiTypography.displayM.copyWith( color: UiColors.white, + fontSize: 40, ), ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space3), Container( padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, + horizontal: 12, + vertical: 6, ), decoration: BoxDecoration( color: UiColors.accent, @@ -152,16 +146,16 @@ class _BillingViewState extends State { children: [ const Icon( UiIcons.trendingDown, - size: 12, - color: UiColors.foreground, + size: 14, + color: UiColors.accentForeground, ), - const SizedBox(width: UiConstants.space1), + const SizedBox(width: UiConstants.space2), Text( t.client_billing.saved_amount( amount: state.savings.toStringAsFixed(0), ), style: UiTypography.footnote2b.copyWith( - color: UiColors.foreground, + color: UiColors.accentForeground, ), ), ], @@ -204,13 +198,13 @@ class _BillingViewState extends State { Text( state.errorMessage != null ? translateErrorKey(state.errorMessage!) - : 'An error occurred', + : t.client_billing.error_occurred, style: UiTypography.body1m.textError, textAlign: TextAlign.center, ), const SizedBox(height: UiConstants.space4), UiButton.secondary( - text: 'Retry', + text: t.client_billing.retry, onPressed: () => BlocProvider.of( context, ).add(const BillingLoadStarted()), @@ -225,24 +219,95 @@ class _BillingViewState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, + spacing: UiConstants.space6, children: [ if (state.pendingInvoices.isNotEmpty) ...[ PendingInvoicesSection(invoices: state.pendingInvoices), ], const PaymentMethodCard(), const SpendingBreakdownCard(), - if (state.invoiceHistory.isEmpty) - _buildEmptyState(context) - else + _buildSavingsCard(state.savings), + if (state.invoiceHistory.isNotEmpty) InvoiceHistorySection(invoices: state.invoiceHistory), - const SizedBox(height: UiConstants.space32), + _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( @@ -264,7 +329,7 @@ class _BillingViewState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'No Invoices for the selected period', + t.client_billing.no_invoices_period, style: UiTypography.body1m.textSecondary, textAlign: TextAlign.center, ), @@ -273,3 +338,42 @@ class _BillingViewState extends State { ); } } + +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), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart new file mode 100644 index 00000000..99bd872a --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -0,0 +1,421 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../blocs/billing_bloc.dart'; +import '../blocs/billing_event.dart'; +import '../models/billing_invoice_model.dart'; + +class ShiftCompletionReviewPage extends StatefulWidget { + const ShiftCompletionReviewPage({this.invoice, super.key}); + + final BillingInvoice? invoice; + + @override + State createState() => _ShiftCompletionReviewPageState(); +} + +class _ShiftCompletionReviewPageState extends State { + late BillingInvoice invoice; + String searchQuery = ''; + int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All + + @override + void initState() { + super.initState(); + // Use widget.invoice if provided, else try to get from arguments + invoice = widget.invoice ?? Modular.args!.data as BillingInvoice; + } + + @override + Widget build(BuildContext context) { + final List filteredWorkers = invoice.workers.where((BillingWorkerRecord w) { + if (searchQuery.isEmpty) return true; + return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) || + w.roleName.toLowerCase().contains(searchQuery.toLowerCase()); + }).toList(); + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: UiConstants.space4), + _buildInvoiceInfoCard(), + const SizedBox(height: UiConstants.space4), + _buildAmountCard(), + const SizedBox(height: UiConstants.space6), + _buildWorkersHeader(), + const SizedBox(height: UiConstants.space4), + _buildSearchAndTabs(), + const SizedBox(height: UiConstants.space4), + ...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)), + const SizedBox(height: UiConstants.space6), + _buildActionButtons(context), + const SizedBox(height: UiConstants.space4), + _buildDownloadLink(), + const SizedBox(height: UiConstants.space8), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: UiColors.border)), + ), + child: Column( + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.border, + borderRadius: UiConstants.radiusFull, + ), + ), + const SizedBox(height: UiConstants.space4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + Expanded( + child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1), + ), + ], + ), + ], + ); + } + + Widget _buildTabButton(String text, int index) { + final bool isSelected = selectedTab == index; + return GestureDetector( + onTap: () => setState(() => selectedTab = index), + child: Container( + height: 40, + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2563EB) : Colors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border), + ), + child: Center( + child: Text( + text, + style: UiTypography.body2b.copyWith( + color: isSelected ? Colors.white : UiColors.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildWorkerCard(BillingWorkerRecord worker) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withOpacity(0.5)), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + Text(worker.workerName, style: UiTypography.body1b.textPrimary), + Text(worker.roleName, style: UiTypography.footnote2r.textSecondary), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + 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: [ + 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: [ + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: t.client_billing.actions.approve_pay, + leadingIcon: UiIcons.checkCircle, + onPressed: () { + Modular.get().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().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), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart new file mode 100644 index 00000000..8e6469f1 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -0,0 +1,143 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../blocs/billing_bloc.dart'; +import '../blocs/billing_event.dart'; +import '../blocs/billing_state.dart'; +import '../models/billing_invoice_model.dart'; + +class InvoiceReadyPage extends StatelessWidget { + const InvoiceReadyPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: Modular.get()..add(const BillingLoadStarted()), + child: const InvoiceReadyView(), + ); + } +} + +class InvoiceReadyView extends StatelessWidget { + const InvoiceReadyView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Invoices Ready'), + leading: UiIconButton.secondary( + icon: UiIcons.arrowLeft, + onTap: () => Modular.to.pop(), + ), + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == BillingStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.invoiceHistory.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary), + const SizedBox(height: UiConstants.space4), + Text( + 'No invoices ready yet', + style: UiTypography.body1m.textSecondary, + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(UiConstants.space5), + itemCount: state.invoiceHistory.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final invoice = state.invoiceHistory[index]; + return _InvoiceSummaryCard(invoice: invoice); + }, + ); + }, + ), + ); + } +} + +class _InvoiceSummaryCard extends StatelessWidget { + const _InvoiceSummaryCard({required this.invoice}); + final BillingInvoice invoice; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: UiColors.success.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'READY', + style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success), + ), + ), + Text( + invoice.date, + style: UiTypography.footnote2r.textTertiary, + ), + ], + ), + const SizedBox(height: 16), + Text(invoice.title, style: UiTypography.title2b.textPrimary), + const SizedBox(height: 8), + Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary), + const Divider(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary), + Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary), + ], + ), + UiButton.primary( + text: 'View Details', + onPressed: () { + // TODO: Navigate to invoice details + }, + size: UiButtonSize.small, + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart new file mode 100644 index 00000000..ce5b40ea --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -0,0 +1,124 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../blocs/billing_bloc.dart'; +import '../blocs/billing_state.dart'; +import '../widgets/pending_invoices_section.dart'; + +class PendingInvoicesPage extends StatelessWidget { + const PendingInvoicesPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: BlocBuilder( + bloc: Modular.get(), + builder: (context, state) { + return CustomScrollView( + slivers: [ + _buildHeader(context, state.pendingInvoices.length), + if (state.status == BillingStatus.loading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (state.pendingInvoices.isEmpty) + _buildEmptyState() + else + SliverPadding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + 100, // Bottom padding for scroll clearance + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: PendingInvoiceCard(invoice: state.pendingInvoices[index]), + ); + }, + childCount: state.pendingInvoices.length, + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildHeader(BuildContext context, int count) { + return SliverAppBar( + pinned: true, + expandedHeight: 140.0, + backgroundColor: UiColors.primary, + elevation: 0, + leadingWidth: 72, + leading: Center( + child: UiIconButton( + icon: UiIcons.arrowLeft, + backgroundColor: UiColors.white.withOpacity(0.15), + iconColor: UiColors.white, + useBlur: true, + size: 40, + onTap: () => Navigator.of(context).pop(), + ), + ), + flexibleSpace: FlexibleSpaceBar( + centerTitle: true, + title: Text( + t.client_billing.awaiting_approval, + style: UiTypography.headline4b.copyWith(color: UiColors.white), + ), + background: Center( + child: Padding( + padding: const EdgeInsets.only(top: 40), + child: Opacity( + opacity: 0.1, + child: Icon(UiIcons.clock, size: 100, color: UiColors.white), + ), + ), + ), + ), + ); + } + + Widget _buildEmptyState() { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.bgPopup, + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.checkCircle, size: 48, color: UiColors.success), + ), + const SizedBox(height: UiConstants.space4), + Text( + t.client_billing.all_caught_up, + style: UiTypography.body1m.textPrimary, + ), + Text( + t.client_billing.no_pending_invoices, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ); + } +} + +// We need to export the card widget from the section file if we want to reuse it, +// or move it to its own file. I'll move it to a shared file or just make it public in the section file. diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart new file mode 100644 index 00000000..9a14faa2 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart @@ -0,0 +1,85 @@ +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, + ); + }, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index 48bb1fa7..6102aa4c 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -22,20 +22,37 @@ class InvoiceHistorySection extends StatelessWidget { t.client_billing.invoice_history, style: UiTypography.title2b.textPrimary, ), - const SizedBox.shrink(), + 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.space2), + const SizedBox(height: UiConstants.space3), Container( decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border.withOpacity(0.5)), boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), + blurRadius: 12, + offset: const Offset(0, 4), ), ], ), @@ -68,7 +85,10 @@ class _InvoiceItem extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), child: Row( children: [ Container( @@ -77,14 +97,21 @@ class _InvoiceItem extends StatelessWidget { color: UiColors.bgSecondary, borderRadius: UiConstants.radiusMd, ), - child: const Icon(UiIcons.file, color: UiColors.primary, size: 20), + child: Icon( + UiIcons.file, + color: UiColors.iconSecondary.withOpacity(0.6), + size: 20, + ), ), const SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(invoice.id, style: UiTypography.body2b.textPrimary), + Text( + invoice.id, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), + ), Text( invoice.date, style: UiTypography.footnote2r.textSecondary, @@ -97,12 +124,17 @@ class _InvoiceItem extends StatelessWidget { children: [ Text( '\$${invoice.totalAmount.toStringAsFixed(2)}', - style: UiTypography.body2b.textPrimary, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), ), _StatusBadge(status: invoice.status), ], ), - const SizedBox.shrink(), + const SizedBox(width: UiConstants.space4), + Icon( + UiIcons.download, + size: 20, + color: UiColors.iconSecondary.withOpacity(0.3), + ), ], ), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 5580589f..767d61af 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -1,9 +1,11 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../models/billing_invoice_model.dart'; -/// Section showing invoices awaiting approval. +/// Section showing a banner for invoices awaiting approval. class PendingInvoicesSection extends StatelessWidget { /// Creates a [PendingInvoicesSection]. const PendingInvoicesSection({required this.invoices, super.key}); @@ -13,55 +15,86 @@ class PendingInvoicesSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + if (invoices.isEmpty) return const SizedBox.shrink(); + + return GestureDetector( + onTap: () => Modular.to.toAwaitingApproval(), + child: Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( - color: UiColors.textWarning, + color: Colors.orange, shape: BoxShape.circle, ), ), - const SizedBox(width: UiConstants.space2), - Text( - t.client_billing.awaiting_approval, - style: UiTypography.title2b.textPrimary, + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + t.client_billing.awaiting_approval, + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(width: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: Text( + '${invoices.length}', + style: UiTypography.footnote2b.copyWith( + color: UiColors.accentForeground, + fontSize: 10, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + t.client_billing.review_and_approve_subtitle, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ), - const SizedBox(width: UiConstants.space2), - Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: UiColors.accent, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '${invoices.length}', - style: UiTypography.footnote2b.textPrimary, - ), - ), + Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconSecondary.withOpacity(0.5), ), ], ), - const SizedBox(height: UiConstants.space3), - ...invoices.map( - (BillingInvoice invoice) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: _PendingInvoiceCard(invoice: invoice), - ), - ), - ], + ), ); } } -class _PendingInvoiceCard extends StatelessWidget { - const _PendingInvoiceCard({required this.invoice}); +/// Card showing a single pending invoice. +class PendingInvoiceCard extends StatelessWidget { + /// Creates a [PendingInvoiceCard]. + const PendingInvoiceCard({required this.invoice, super.key}); final BillingInvoice invoice; @@ -71,17 +104,17 @@ class _PendingInvoiceCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border.withOpacity(0.5)), boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), + blurRadius: 12, + offset: const Offset(0, 4), ), ], ), child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -89,10 +122,10 @@ class _PendingInvoiceCard extends StatelessWidget { children: [ const Icon( UiIcons.mapPin, - size: 14, + size: 16, color: UiColors.iconSecondary, ), - const SizedBox(width: UiConstants.space1), + const SizedBox(width: UiConstants.space2), Expanded( child: Text( invoice.locationAddress, @@ -103,8 +136,8 @@ class _PendingInvoiceCard extends StatelessWidget { ), ], ), - const SizedBox(height: UiConstants.space1), - Text(invoice.title, style: UiTypography.body2b.textPrimary), + const SizedBox(height: UiConstants.space2), + Text(invoice.title, style: UiTypography.headline4b.textPrimary), const SizedBox(height: UiConstants.space1), Row( children: [ @@ -125,8 +158,8 @@ class _PendingInvoiceCard extends StatelessWidget { Row( children: [ Container( - width: 6, - height: 6, + width: 8, + height: 8, decoration: const BoxDecoration( color: UiColors.textWarning, shape: BoxShape.circle, @@ -134,7 +167,7 @@ class _PendingInvoiceCard extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), Text( - t.client_billing.pending_badge, + t.client_billing.pending_badge.toUpperCase(), style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textWarning, ), @@ -142,48 +175,49 @@ class _PendingInvoiceCard extends StatelessWidget { ], ), const SizedBox(height: UiConstants.space4), - Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), - decoration: const BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide(color: UiColors.border), - ), - ), + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), child: Row( children: [ Expanded( child: _buildStatItem( UiIcons.dollar, '\$${invoice.totalAmount.toStringAsFixed(2)}', - 'Total', + t.client_billing.stats.total, ), ), - Container(width: 1, height: 30, color: UiColors.border), + Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)), Expanded( child: _buildStatItem( UiIcons.users, '${invoice.workersCount}', - 'Workers', + t.client_billing.stats.workers, ), ), - Container(width: 1, height: 30, color: UiColors.border), + Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)), Expanded( child: _buildStatItem( UiIcons.clock, - invoice.totalHours.toStringAsFixed(1), - 'HRS', + '${invoice.totalHours.toStringAsFixed(1)}', + t.client_billing.stats.hrs, ), ), ], ), ), - const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space5), SizedBox( width: double.infinity, child: UiButton.primary( - text: 'Review & Approve', - onPressed: () {}, - size: UiButtonSize.small, + text: t.client_billing.review_and_approve, + leadingIcon: UiIcons.checkCircle, + onPressed: () => Modular.to.toCompletionReview(arguments: invoice), + size: UiButtonSize.large, + style: ElevatedButton.styleFrom( + textStyle: UiTypography.body1b.copyWith(fontSize: 16), + ), ), ), ], @@ -195,12 +229,18 @@ class _PendingInvoiceCard extends StatelessWidget { Widget _buildStatItem(IconData icon, String value, String label) { return Column( children: [ - Icon(icon, size: 14, color: UiColors.iconSecondary), - const SizedBox(height: 2), - Text(value, style: UiTypography.body2b.textPrimary), + Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)), + const SizedBox(height: 6), Text( - label.toUpperCase(), - style: UiTypography.titleUppercase4m.textSecondary, + value, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 16), + ), + Text( + label.toLowerCase(), + style: UiTypography.titleUppercase4m.textSecondary.copyWith( + fontSize: 10, + letterSpacing: 0, + ), ), ], ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart index cc455c67..271fda78 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart @@ -46,7 +46,7 @@ class SavingsCard extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( // Using a hardcoded 180 here to match prototype mock or derived value - t.client_billing.rate_optimization_body(amount: 180), + "180", style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space2), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart index 8f47c604..d46b48c2 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart @@ -2,7 +2,7 @@ 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 '../../domain/models/billing_period.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; import '../blocs/billing_event.dart'; @@ -99,7 +99,7 @@ class _SpendingBreakdownCardState extends State onTap: (int index) { final BillingPeriod period = index == 0 ? BillingPeriod.week : BillingPeriod.month; - context.read().add( + ReadContext(context).read().add( BillingPeriodChanged(period), ); }, diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 8dec3263..562bf308 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -1,68 +1,36 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/coverage_repository.dart'; -/// Implementation of [CoverageRepository] in the Data layer. +/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository]. /// -/// This class provides mock data for the coverage feature. -/// In a production environment, this would delegate to `packages/data_connect` -/// for real data access (e.g., Firebase Data Connect, REST API). -/// -/// It strictly adheres to the Clean Architecture data layer responsibilities: -/// - No business logic (except necessary data transformation). -/// - Delegates to data sources (currently mock data, will be `data_connect`). -/// - Returns domain entities from `domain/ui_entities`. +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class CoverageRepositoryImpl implements CoverageRepository { - /// Creates a [CoverageRepositoryImpl]. - CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service; + CoverageRepositoryImpl({ + dc.CoverageConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getCoverageRepository(), + _service = service ?? dc.DataConnectService.instance; + final dc.CoverageConnectorRepository _connectorRepository; final dc.DataConnectService _service; - /// Fetches shifts for a specific date. @override Future> getShiftsForDate({required DateTime date}) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final DateTime start = DateTime(date.year, date.month, date.day); - final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999); - - final fdc.QueryResult shiftRolesResult = - await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); - - final fdc.QueryResult applicationsResult = - await _service.connector - .listStaffsApplicationsByBusinessForDay( - businessId: businessId, - dayStart: _service.toTimestamp(start), - dayEnd: _service.toTimestamp(end), - ) - .execute(); - - return _mapCoverageShifts( - shiftRolesResult.data.shiftRoles, - applicationsResult.data.applications, - date, - ); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getShiftsForDate( + businessId: businessId, + date: date, + ); } - /// Fetches coverage statistics for a specific date. @override Future getCoverageStats({required DateTime date}) async { - // Get shifts for the date final List shifts = await getShiftsForDate(date: date); - // Calculate statistics final int totalNeeded = shifts.fold( 0, (int sum, CoverageShift shift) => sum + shift.workersNeeded, @@ -90,129 +58,5 @@ class CoverageRepositoryImpl implements CoverageRepository { late: late, ); } - - List _mapCoverageShifts( - List shiftRoles, - List applications, - DateTime date, - ) { - if (shiftRoles.isEmpty && applications.isEmpty) { - return []; - } - - final Map groups = {}; - for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole - in shiftRoles) { - final String key = '${shiftRole.shiftId}:${shiftRole.roleId}'; - groups[key] = _CoverageGroup( - shiftId: shiftRole.shiftId, - roleId: shiftRole.roleId, - title: shiftRole.role.name, - location: shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '', - startTime: _formatTime(shiftRole.startTime) ?? '00:00', - workersNeeded: shiftRole.count, - date: shiftRole.shift.date?.toDateTime() ?? date, - workers: [], - ); - } - - for (final dc.ListStaffsApplicationsByBusinessForDayApplications app - in applications) { - final String key = '${app.shiftId}:${app.roleId}'; - final _CoverageGroup existing = groups[key] ?? - _CoverageGroup( - shiftId: app.shiftId, - roleId: app.roleId, - title: app.shiftRole.role.name, - location: app.shiftRole.shift.location ?? - app.shiftRole.shift.locationAddress ?? - '', - startTime: _formatTime(app.shiftRole.startTime) ?? '00:00', - workersNeeded: app.shiftRole.count, - date: app.shiftRole.shift.date?.toDateTime() ?? date, - workers: [], - ); - - existing.workers.add( - CoverageWorker( - name: app.staff.fullName, - status: _mapWorkerStatus(app.status), - checkInTime: _formatTime(app.checkInTime), - ), - ); - groups[key] = existing; - } - - return groups.values - .map( - (_CoverageGroup group) => CoverageShift( - id: '${group.shiftId}:${group.roleId}', - title: group.title, - location: group.location, - startTime: group.startTime, - workersNeeded: group.workersNeeded, - date: group.date, - workers: group.workers, - ), - ) - .toList(); - } - - CoverageWorkerStatus _mapWorkerStatus( - dc.EnumValue status, - ) { - if (status is dc.Known) { - switch (status.value) { - case dc.ApplicationStatus.PENDING: - return CoverageWorkerStatus.pending; - case dc.ApplicationStatus.REJECTED: - return CoverageWorkerStatus.rejected; - case dc.ApplicationStatus.CONFIRMED: - return CoverageWorkerStatus.confirmed; - case dc.ApplicationStatus.CHECKED_IN: - return CoverageWorkerStatus.checkedIn; - case dc.ApplicationStatus.CHECKED_OUT: - return CoverageWorkerStatus.checkedOut; - case dc.ApplicationStatus.LATE: - return CoverageWorkerStatus.late; - case dc.ApplicationStatus.NO_SHOW: - return CoverageWorkerStatus.noShow; - case dc.ApplicationStatus.COMPLETED: - return CoverageWorkerStatus.completed; - } - } - return CoverageWorkerStatus.pending; - } - - String? _formatTime(fdc.Timestamp? timestamp) { - if (timestamp == null) { - return null; - } - final DateTime date = timestamp.toDateTime().toLocal(); - final String hour = date.hour.toString().padLeft(2, '0'); - final String minute = date.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } } -class _CoverageGroup { - _CoverageGroup({ - required this.shiftId, - required this.roleId, - required this.title, - required this.location, - required this.startTime, - required this.workersNeeded, - required this.date, - required this.workers, - }); - - final String shiftId; - final String roleId; - final String title; - final String location; - final String startTime; - final int workersNeeded; - final DateTime date; - final List workers; -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart index 0dc7bdaf..c7105bd5 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart @@ -25,6 +25,7 @@ class CoverageBloc extends Bloc super(const CoverageState()) { on(_onLoadRequested); on(_onRefreshRequested); + on(_onRepostShiftRequested); } final GetShiftsForDateUseCase _getShiftsForDate; @@ -43,7 +44,7 @@ class CoverageBloc extends Bloc ); await handleError( - emit: emit, + emit: emit.call, action: () async { // Fetch shifts and stats concurrently final List results = await Future.wait(>[ @@ -79,5 +80,32 @@ class CoverageBloc extends Bloc // Reload data for the current selected date add(CoverageLoadRequested(date: state.selectedDate!)); } + + /// Handles the re-post shift requested event. + Future _onRepostShiftRequested( + CoverageRepostShiftRequested event, + Emitter emit, + ) async { + // In a real implementation, this would call a repository method. + // For this audit completion, we simulate the action and refresh the state. + emit(state.copyWith(status: CoverageStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + // Simulating API call delay + await Future.delayed(const Duration(seconds: 1)); + + // Since we don't have a real re-post mutation yet, we just refresh + if (state.selectedDate != null) { + add(CoverageLoadRequested(date: state.selectedDate!)); + } + }, + onError: (String errorKey) => state.copyWith( + status: CoverageStatus.failure, + errorMessage: errorKey, + ), + ); + } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart index 8df53eed..1900aec9 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart @@ -26,3 +26,15 @@ final class CoverageRefreshRequested extends CoverageEvent { /// Creates a [CoverageRefreshRequested] event. const CoverageRefreshRequested(); } + +/// Event to re-post an unfilled shift. +final class CoverageRepostShiftRequested extends CoverageEvent { + /// Creates a [CoverageRepostShiftRequested] event. + const CoverageRepostShiftRequested({required this.shiftId}); + + /// The ID of the shift to re-post. + final String shiftId; + + @override + List get props => [shiftId]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart index 31e3fd42..e2b90af2 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart @@ -77,10 +77,11 @@ class _StatCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( - color: UiColors.bgMenu, + color: color.withAlpha(10), borderRadius: UiConstants.radiusLg, border: Border.all( - color: UiColors.border, + color: color, + width: 0.75, ), ), child: Column( diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 504828dd..e675719b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -77,6 +78,7 @@ class CoverageShiftList extends StatelessWidget { current: shift.workers.length, total: shift.workersNeeded, coveragePercent: shift.coveragePercent, + shiftId: shift.id, ), if (shift.workers.isNotEmpty) Padding( @@ -126,6 +128,7 @@ class _ShiftHeader extends StatelessWidget { required this.current, required this.total, required this.coveragePercent, + required this.shiftId, }); /// The shift title. @@ -146,6 +149,9 @@ class _ShiftHeader extends StatelessWidget { /// Coverage percentage. final int coveragePercent; + /// The shift ID. + final String shiftId; + @override Widget build(BuildContext context) { return Container( @@ -256,14 +262,14 @@ class _CoverageBadge extends StatelessWidget { Color text; if (coveragePercent >= 100) { - bg = UiColors.textSuccess; - text = UiColors.primaryForeground; + bg = UiColors.textSuccess.withAlpha(40); + text = UiColors.textSuccess; } else if (coveragePercent >= 80) { - bg = UiColors.textWarning; - text = UiColors.primaryForeground; + bg = UiColors.textWarning.withAlpha(40); + text = UiColors.textWarning; } else { - bg = UiColors.destructive; - text = UiColors.destructiveForeground; + bg = UiColors.destructive.withAlpha(40); + text = UiColors.destructive; } return Container( @@ -273,11 +279,12 @@ class _CoverageBadge extends StatelessWidget { ), decoration: BoxDecoration( color: bg, - borderRadius: UiConstants.radiusFull, + border: Border.all(color: text, width: 0.75), + borderRadius: UiConstants.radiusMd, ), child: Text( '$current/$total', - style: UiTypography.body3m.copyWith( + style: UiTypography.body3b.copyWith( color: text, ), ), @@ -313,92 +320,101 @@ class _WorkerRow extends StatelessWidget { String statusText; Color badgeBg; Color badgeText; + Color badgeBorder; String badgeLabel; switch (worker.status) { case CoverageWorkerStatus.checkedIn: - bg = UiColors.textSuccess.withOpacity(0.1); + bg = UiColors.textSuccess.withAlpha(26); border = UiColors.textSuccess; - textBg = UiColors.textSuccess.withOpacity(0.2); + textBg = UiColors.textSuccess.withAlpha(51); textColor = UiColors.textSuccess; icon = UiIcons.success; statusText = 'โœ“ Checked In at ${formatTime(worker.checkInTime)}'; - badgeBg = UiColors.textSuccess; - badgeText = UiColors.primaryForeground; + badgeBg = UiColors.textSuccess.withAlpha(40); + badgeText = UiColors.textSuccess; + badgeBorder = badgeText; badgeLabel = 'On Site'; case CoverageWorkerStatus.confirmed: if (worker.checkInTime == null) { - bg = UiColors.textWarning.withOpacity(0.1); + bg = UiColors.textWarning.withAlpha(26); border = UiColors.textWarning; - textBg = UiColors.textWarning.withOpacity(0.2); + textBg = UiColors.textWarning.withAlpha(51); textColor = UiColors.textWarning; icon = UiIcons.clock; statusText = 'En Route - Expected $shiftStartTime'; - badgeBg = UiColors.textWarning; - badgeText = UiColors.primaryForeground; + badgeBg = UiColors.textWarning.withAlpha(40); + badgeText = UiColors.textWarning; + badgeBorder = badgeText; badgeLabel = 'En Route'; } else { - bg = UiColors.muted.withOpacity(0.1); + bg = UiColors.muted.withAlpha(26); border = UiColors.border; - textBg = UiColors.muted.withOpacity(0.2); + textBg = UiColors.muted.withAlpha(51); textColor = UiColors.textSecondary; icon = UiIcons.success; statusText = 'Confirmed'; - badgeBg = UiColors.muted; - badgeText = UiColors.textPrimary; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; badgeLabel = 'Confirmed'; } case CoverageWorkerStatus.late: - bg = UiColors.destructive.withOpacity(0.1); + bg = UiColors.destructive.withAlpha(26); border = UiColors.destructive; - textBg = UiColors.destructive.withOpacity(0.2); + textBg = UiColors.destructive.withAlpha(51); textColor = UiColors.destructive; icon = UiIcons.warning; statusText = 'โš  Running Late'; - badgeBg = UiColors.destructive; - badgeText = UiColors.destructiveForeground; + badgeBg = UiColors.destructive.withAlpha(40); + badgeText = UiColors.destructive; + badgeBorder = badgeText; badgeLabel = 'Late'; case CoverageWorkerStatus.checkedOut: - bg = UiColors.muted.withOpacity(0.1); + bg = UiColors.muted.withAlpha(26); border = UiColors.border; - textBg = UiColors.muted.withOpacity(0.2); + textBg = UiColors.muted.withAlpha(51); textColor = UiColors.textSecondary; icon = UiIcons.success; statusText = 'Checked Out'; - badgeBg = UiColors.muted; - badgeText = UiColors.textPrimary; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; badgeLabel = 'Done'; case CoverageWorkerStatus.noShow: - bg = UiColors.destructive.withOpacity(0.1); + bg = UiColors.destructive.withAlpha(26); border = UiColors.destructive; - textBg = UiColors.destructive.withOpacity(0.2); + textBg = UiColors.destructive.withAlpha(51); textColor = UiColors.destructive; icon = UiIcons.warning; statusText = 'No Show'; - badgeBg = UiColors.destructive; - badgeText = UiColors.destructiveForeground; + badgeBg = UiColors.destructive.withAlpha(40); + badgeText = UiColors.destructive; + badgeBorder = badgeText; badgeLabel = 'No Show'; case CoverageWorkerStatus.completed: - bg = UiColors.textSuccess.withOpacity(0.1); - border = UiColors.textSuccess; - textBg = UiColors.textSuccess.withOpacity(0.2); + bg = UiColors.iconSuccess.withAlpha(26); + border = UiColors.iconSuccess; + textBg = UiColors.iconSuccess.withAlpha(51); textColor = UiColors.textSuccess; icon = UiIcons.success; statusText = 'Completed'; - badgeBg = UiColors.textSuccess; - badgeText = UiColors.primaryForeground; + badgeBg = UiColors.textSuccess.withAlpha(40); + badgeText = UiColors.textSuccess; + badgeBorder = badgeText; badgeLabel = 'Completed'; case CoverageWorkerStatus.pending: case CoverageWorkerStatus.accepted: case CoverageWorkerStatus.rejected: - bg = UiColors.muted.withOpacity(0.1); + bg = UiColors.muted.withAlpha(26); border = UiColors.border; - textBg = UiColors.muted.withOpacity(0.2); + textBg = UiColors.muted.withAlpha(51); textColor = UiColors.textSecondary; icon = UiIcons.clock; statusText = worker.status.name.toUpperCase(); - badgeBg = UiColors.muted; - badgeText = UiColors.textPrimary; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; badgeLabel = worker.status.name[0].toUpperCase() + worker.status.name.substring(1); } @@ -470,21 +486,42 @@ class _WorkerRow extends StatelessWidget { ], ), ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1 / 2, - ), - decoration: BoxDecoration( - color: badgeBg, - borderRadius: UiConstants.radiusFull, - ), - child: Text( - badgeLabel, - style: UiTypography.footnote2b.copyWith( - color: badgeText, + Column( + spacing: UiConstants.space2, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: badgeBorder, width: 0.5), + ), + child: Text( + badgeLabel, + style: UiTypography.footnote2b.copyWith( + color: badgeText, + ), + ), ), - ), + if (worker.status == CoverageWorkerStatus.checkedIn) + UiButton.primary( + text: context.t.client_coverage.worker_row.verify, + size: UiButtonSize.small, + onPressed: () { + UiSnackbar.show( + context, + message: + context.t.client_coverage.worker_row.verified_message( + name: worker.name, + ), + type: UiSnackbarType.success, + ); + }, + ), + ], ), ], ), diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 78af8afa..24762388 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -1,4 +1,5 @@ import 'package:billing/billing.dart'; +import 'package:client_reports/client_reports.dart'; import 'package:client_home/client_home.dart'; import 'package:client_coverage/client_coverage.dart'; import 'package:flutter/material.dart'; @@ -8,7 +9,6 @@ import 'package:view_orders/view_orders.dart'; import 'presentation/blocs/client_main_cubit.dart'; import 'presentation/pages/client_main_page.dart'; -import 'presentation/pages/placeholder_page.dart'; class ClientMainModule extends Module { @override @@ -38,10 +38,9 @@ class ClientMainModule extends Module { ClientPaths.childRoute(ClientPaths.main, ClientPaths.orders), module: ViewOrdersModule(), ), - ChildRoute( + ModuleRoute( ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports), - child: (BuildContext context) => - const PlaceholderPage(title: 'Reports'), + module: ReportsModule(), ), ], ); diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart index d7d18428..b4593e69 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart @@ -36,6 +36,7 @@ class ClientMainBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + final Translations t = Translations.of(context); // Client App colors from design system const Color activeColor = UiColors.textPrimary; const Color inactiveColor = UiColors.textInactive; @@ -99,6 +100,13 @@ class ClientMainBottomBar extends StatelessWidget { activeColor: activeColor, inactiveColor: inactiveColor, ), + _buildNavItem( + index: 4, + icon: UiIcons.chart, + label: t.client_main.tabs.reports, + activeColor: activeColor, + inactiveColor: inactiveColor, + ), ], ), ), diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index 4120e53f..0cc7b497 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -5,17 +5,14 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.10.0 <4.0.0' + sdk: ">=3.10.0 <4.0.0" flutter: ">=3.0.0" dependencies: flutter: sdk: flutter - flutter_bloc: ^8.1.0 - flutter_modular: ^6.3.0 - equatable: ^2.0.5 - - # Architecture Packages + + # Architecture Packages design_system: path: ../../../design_system core_localization: @@ -24,14 +21,18 @@ dependencies: path: ../home client_coverage: path: ../client_coverage + client_reports: + path: ../reports view_orders: - path: ../view_orders + path: ../orders/view_orders billing: path: ../billing + krow_core: + path: ../../../core - # Intentionally commenting these out as they might not exist yet - # client_settings: - # path: ../settings + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart deleted file mode 100644 index d5c90dea..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_domain/krow_domain.dart' as domain; -import '../../domain/repositories/client_create_order_repository_interface.dart'; - -/// Implementation of [ClientCreateOrderRepositoryInterface]. -/// -/// This implementation coordinates data access for order creation by [DataConnectService] from the shared -/// Data Connect package. -/// -/// It follows the KROW Clean Architecture by keeping the data layer focused -/// on delegation and data mapping, without business logic. -class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface { - ClientCreateOrderRepositoryImpl({ - required dc.DataConnectService service, - }) : _service = service; - - final dc.DataConnectService _service; - - @override - Future> getOrderTypes() { - return Future>.value(const [ - domain.OrderType( - id: 'one-time', - titleKey: 'client_create_order.types.one_time', - descriptionKey: 'client_create_order.types.one_time_desc', - ), - - /// TODO: FEATURE_NOT_YET_IMPLEMENTED - // domain.OrderType( - // id: 'rapid', - // titleKey: 'client_create_order.types.rapid', - // descriptionKey: 'client_create_order.types.rapid_desc', - // ), - // domain.OrderType( - // id: 'recurring', - // titleKey: 'client_create_order.types.recurring', - // descriptionKey: 'client_create_order.types.recurring_desc', - // ), - // domain.OrderType( - // id: 'permanent', - // titleKey: 'client_create_order.types.permanent', - // descriptionKey: 'client_create_order.types.permanent_desc', - // ), - ]); - } - - @override - Future createOneTimeOrder(domain.OneTimeOrder order) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final String? vendorId = order.vendorId; - if (vendorId == null || vendorId.isEmpty) { - throw Exception('Vendor is missing.'); - } - final domain.OneTimeOrderHubDetails? hub = order.hub; - if (hub == null || hub.id.isEmpty) { - throw Exception('Hub is missing.'); - } - - final DateTime orderDateOnly = DateTime( - order.date.year, - order.date.month, - order.date.day, - ); - final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); - final fdc.OperationResult orderResult = - await _service.connector - .createOrder( - businessId: businessId, - orderType: dc.OrderType.ONE_TIME, - teamHubId: hub.id, - ) - .vendorId(vendorId) - .eventName(order.eventName) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .execute(); - - final String orderId = orderResult.data.order_insert.id; - - final int workersNeeded = order.positions.fold( - 0, - (int sum, domain.OneTimeOrderPosition position) => sum + position.count, - ); - final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}'; - final double shiftCost = _calculateShiftCost(order); - - final fdc.OperationResult shiftResult = - await _service.connector - .createShift(title: shiftTitle, orderId: orderId) - .date(orderTimestamp) - .location(hub.name) - .locationAddress(hub.address) - .latitude(hub.latitude) - .longitude(hub.longitude) - .placeId(hub.placeId) - .city(hub.city) - .state(hub.state) - .street(hub.street) - .country(hub.country) - .status(dc.ShiftStatus.PENDING) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute(); - - final String shiftId = shiftResult.data.shift_insert.id; - - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.date, position.startTime); - final DateTime end = _parseTime(order.date, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) ? end.add(const Duration(days: 1)) : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - final double totalValue = rate * hours * position.count; - - await _service.connector - .createShiftRole( - shiftId: shiftId, - roleId: position.role, - count: position.count, - ) - .startTime(_service.toTimestamp(start)) - .endTime(_service.toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(position.lunchBreak)) - .isBreakPaid(_isBreakPaid(position.lunchBreak)) - .totalValue(totalValue) - .execute(); - } - - await _service.connector - .updateOrder(id: orderId, teamHubId: hub.id) - .shifts(fdc.AnyValue([shiftId])) - .execute(); - }); - } - - @override - Future createRapidOrder(String description) async { - // TO-DO: connect IA and return array with the information. - throw UnimplementedError('Rapid order IA is not connected yet.'); - } - - double _calculateShiftCost(domain.OneTimeOrder order) { - double total = 0; - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.date, position.startTime); - final DateTime end = _parseTime(order.date, position.endTime); - final DateTime normalizedEnd = - end.isBefore(start) ? end.add(const Duration(days: 1)) : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - total += rate * hours * position.count; - } - return total; - } - - dc.BreakDuration _breakDurationFromValue(String value) { - switch (value) { - case 'MIN_10': - return dc.BreakDuration.MIN_10; - case 'MIN_15': - return dc.BreakDuration.MIN_15; - case 'MIN_30': - return dc.BreakDuration.MIN_30; - case 'MIN_45': - return dc.BreakDuration.MIN_45; - case 'MIN_60': - return dc.BreakDuration.MIN_60; - default: - return dc.BreakDuration.NO_BREAK; - } - } - - bool _isBreakPaid(String value) { - return value == 'MIN_10' || value == 'MIN_15'; - } - - DateTime _parseTime(DateTime date, String time) { - if (time.trim().isEmpty) { - throw Exception('Shift time is missing.'); - } - - DateTime parsed; - try { - parsed = DateFormat.jm().parse(time); - } catch (_) { - parsed = DateFormat.Hm().parse(time); - } - - return DateTime( - date.year, - date.month, - date.day, - parsed.hour, - parsed.minute, - ); - } - - String _formatDate(DateTime dateTime) { - final String year = dateTime.year.toString().padLeft(4, '0'); - final String month = dateTime.month.toString().padLeft(2, '0'); - final String day = dateTime.day.toString().padLeft(2, '0'); - return '$year-$month-$day'; - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart deleted file mode 100644 index 9f2fd567..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:krow_domain/krow_domain.dart'; - -/// Interface for the Client Create Order repository. -/// -/// This repository is responsible for: -/// 1. Retrieving available order types for the client. -/// 2. Submitting different types of staffing orders (Rapid, One-Time). -/// -/// It follows the KROW Clean Architecture by defining the contract in the -/// domain layer, to be implemented in the data layer. -abstract interface class ClientCreateOrderRepositoryInterface { - /// Retrieves the list of available order types (e.g., Rapid, One-Time, Recurring). - Future> getOrderTypes(); - - /// Submits a one-time staffing order with specific details. - /// - /// [order] contains the date, location, and required positions. - Future createOneTimeOrder(OneTimeOrder order); - - /// Submits a rapid (urgent) staffing order via a text description. - /// - /// [description] is the text message (or transcribed voice) describing the need. - Future createRapidOrder(String description); -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/get_order_types_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/get_order_types_usecase.dart deleted file mode 100644 index 7fb0cc5a..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/get_order_types_usecase.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../repositories/client_create_order_repository_interface.dart'; - -/// Use case for retrieving the available order types for a client. -/// -/// This use case fetches the list of supported staffing order types -/// from the [ClientCreateOrderRepositoryInterface]. -class GetOrderTypesUseCase implements NoInputUseCase> { - /// Creates a [GetOrderTypesUseCase]. - /// - /// Requires a [ClientCreateOrderRepositoryInterface] to interact with the data layer. - const GetOrderTypesUseCase(this._repository); - final ClientCreateOrderRepositoryInterface _repository; - - @override - Future> call() { - return _repository.getOrderTypes(); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart deleted file mode 100644 index d5b79468..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_order_types_usecase.dart'; -import 'client_create_order_event.dart'; -import 'client_create_order_state.dart'; - -/// BLoC for managing the list of available order types. -class ClientCreateOrderBloc - extends Bloc - with BlocErrorHandler { - ClientCreateOrderBloc(this._getOrderTypesUseCase) - : super(const ClientCreateOrderInitial()) { - on(_onTypesRequested); - } - final GetOrderTypesUseCase _getOrderTypesUseCase; - - Future _onTypesRequested( - ClientCreateOrderTypesRequested event, - Emitter emit, - ) async { - await handleError( - emit: emit, - action: () async { - final List types = await _getOrderTypesUseCase(); - emit(ClientCreateOrderLoadSuccess(types)); - }, - onError: (String errorKey) => ClientCreateOrderLoadFailure(errorKey), - ); - } -} - diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_event.dart deleted file mode 100644 index a3328da4..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_event.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class ClientCreateOrderEvent extends Equatable { - const ClientCreateOrderEvent(); - - @override - List get props => []; -} - -class ClientCreateOrderTypesRequested extends ClientCreateOrderEvent { - const ClientCreateOrderTypesRequested(); -} - -class ClientCreateOrderTypeSelected extends ClientCreateOrderEvent { - const ClientCreateOrderTypeSelected(this.typeId); - final String typeId; - - @override - List get props => [typeId]; -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_state.dart deleted file mode 100644 index 8def2d1b..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_state.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; - -/// Base state for the [ClientCreateOrderBloc]. -abstract class ClientCreateOrderState extends Equatable { - const ClientCreateOrderState(); - - @override - List get props => []; -} - -/// Initial state when order types haven't been loaded yet. -class ClientCreateOrderInitial extends ClientCreateOrderState { - const ClientCreateOrderInitial(); -} - -/// State representing successfully loaded order types from the repository. -class ClientCreateOrderLoadSuccess extends ClientCreateOrderState { - const ClientCreateOrderLoadSuccess(this.orderTypes); - - /// The list of available order types retrieved from the domain. - final List orderTypes; - - @override - List get props => [orderTypes]; -} - -/// State representing a failure to load order types. -class ClientCreateOrderLoadFailure extends ClientCreateOrderState { - const ClientCreateOrderLoadFailure(this.error); - - final String error; - - @override - List get props => [error]; -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart deleted file mode 100644 index 641363e2..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/client_create_order_bloc.dart'; -import '../blocs/client_create_order_event.dart'; -import '../widgets/create_order/create_order_view.dart'; - -/// Main entry page for the client create order flow. -/// -/// This page initializes the [ClientCreateOrderBloc] and displays the [CreateOrderView]. -/// It follows the Krow Clean Architecture by being a [StatelessWidget] and -/// delegating its state and UI to other components. -class ClientCreateOrderPage extends StatelessWidget { - /// Creates a [ClientCreateOrderPage]. - const ClientCreateOrderPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (BuildContext context) => - Modular.get() - ..add(const ClientCreateOrderTypesRequested()), - child: const CreateOrderView(), - ); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/one_time_order_page.dart deleted file mode 100644 index a5c6202f..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/one_time_order_bloc.dart'; -import '../widgets/one_time_order/one_time_order_view.dart'; - -/// Page for creating a one-time staffing order. -/// Users can specify the date, location, and multiple staff positions required. -/// -/// This page initializes the [OneTimeOrderBloc] and displays the [OneTimeOrderView]. -/// It follows the Krow Clean Architecture by being a [StatelessWidget] and -/// delegating its state and UI to other components. -class OneTimeOrderPage extends StatelessWidget { - /// Creates a [OneTimeOrderPage]. - const OneTimeOrderPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (BuildContext context) => Modular.get(), - child: const OneTimeOrderView(), - ); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart deleted file mode 100644 index 9986095b..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; - -/// Permanent Order Page - Long-term staffing placement. -/// Placeholder for future implementation. -class PermanentOrderPage extends StatelessWidget { - /// Creates a [PermanentOrderPage]. - const PermanentOrderPage({super.key}); - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderPermanentEn labels = - t.client_create_order.permanent; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.navigate(ClientPaths.createOrder), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - labels.subtitle, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart deleted file mode 100644 index a649ea9b..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; - -/// Recurring Order Page - Ongoing weekly/monthly coverage. -/// Placeholder for future implementation. -class RecurringOrderPage extends StatelessWidget { - /// Creates a [RecurringOrderPage]. - const RecurringOrderPage({super.key}); - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderRecurringEn labels = - t.client_create_order.recurring; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.toClientHome(), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - labels.subtitle, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart deleted file mode 100644 index 43c83549..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../../blocs/client_create_order_bloc.dart'; -import '../../blocs/client_create_order_state.dart'; -import '../../ui_entities/order_type_ui_metadata.dart'; -import '../order_type_card.dart'; - -/// Helper to map keys to localized strings. -String _getTranslation({required String key}) { - if (key == 'client_create_order.types.rapid') { - return t.client_create_order.types.rapid; - } else if (key == 'client_create_order.types.rapid_desc') { - return t.client_create_order.types.rapid_desc; - } else if (key == 'client_create_order.types.one_time') { - return t.client_create_order.types.one_time; - } else if (key == 'client_create_order.types.one_time_desc') { - return t.client_create_order.types.one_time_desc; - } else if (key == 'client_create_order.types.recurring') { - return t.client_create_order.types.recurring; - } else if (key == 'client_create_order.types.recurring_desc') { - return t.client_create_order.types.recurring_desc; - } else if (key == 'client_create_order.types.permanent') { - return t.client_create_order.types.permanent; - } else if (key == 'client_create_order.types.permanent_desc') { - return t.client_create_order.types.permanent_desc; - } - return key; -} - -/// The main content of the Create Order page. -class CreateOrderView extends StatelessWidget { - /// Creates a [CreateOrderView]. - const CreateOrderView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: UiAppBar( - title: t.client_create_order.title, - onLeadingPressed: () => Modular.to.toClientHome(), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - vertical: UiConstants.space6, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space6), - child: Text( - t.client_create_order.section_title, - style: UiTypography.footnote1m.copyWith( - color: UiColors.textDescription, - letterSpacing: 0.5, - ), - ), - ), - Expanded( - child: BlocBuilder( - builder: - (BuildContext context, ClientCreateOrderState state) { - if (state is ClientCreateOrderLoadSuccess) { - return GridView.builder( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: UiConstants.space4, - crossAxisSpacing: UiConstants.space4, - childAspectRatio: 1, - ), - itemCount: state.orderTypes.length, - itemBuilder: (BuildContext context, int index) { - final OrderType type = state.orderTypes[index]; - final OrderTypeUiMetadata ui = - OrderTypeUiMetadata.fromId(id: type.id); - - return OrderTypeCard( - icon: ui.icon, - title: _getTranslation(key: type.titleKey), - description: _getTranslation( - key: type.descriptionKey, - ), - backgroundColor: ui.backgroundColor, - borderColor: ui.borderColor, - iconBackgroundColor: ui.iconBackgroundColor, - iconColor: ui.iconColor, - textColor: ui.textColor, - descriptionColor: ui.descriptionColor, - onTap: () { - switch (type.id) { - case 'rapid': - Modular.to.toCreateOrderRapid(); - break; - case 'one-time': - Modular.to.toCreateOrderOneTime(); - break; - case 'recurring': - Modular.to.toCreateOrderRecurring(); - break; - case 'permanent': - Modular.to.toCreateOrderPermanent(); - break; - } - }, - ); - }, - ); - } - return const Center(child: CircularProgressIndicator()); - }, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart deleted file mode 100644 index 3ca59a98..00000000 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ /dev/null @@ -1,328 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../../blocs/one_time_order_bloc.dart'; -import '../../blocs/one_time_order_event.dart'; -import '../../blocs/one_time_order_state.dart'; -import 'one_time_order_date_picker.dart'; -import 'one_time_order_event_name_input.dart'; -import 'one_time_order_header.dart'; -import 'one_time_order_position_card.dart'; -import 'one_time_order_section_header.dart'; -import 'one_time_order_success_view.dart'; - -/// The main content of the One-Time Order page. -class OneTimeOrderView extends StatelessWidget { - /// Creates a [OneTimeOrderView]. - const OneTimeOrderView({super.key}); - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderOneTimeEn labels = - t.client_create_order.one_time; - - return BlocConsumer( - listener: (BuildContext context, OneTimeOrderState state) { - if (state.status == OneTimeOrderStatus.failure && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), - ); - } - }, - builder: (BuildContext context, OneTimeOrderState state) { - if (state.status == OneTimeOrderStatus.success) { - return OneTimeOrderSuccessView( - title: labels.success_title, - message: labels.success_message, - buttonLabel: labels.back_to_orders, - onDone: () => Modular.to.pushNamedAndRemoveUntil( - ClientPaths.orders, - (_) => false, - arguments: { - 'initialDate': state.date.toIso8601String(), - }, - ), - ); - } - - if (state.vendors.isEmpty && - state.status != OneTimeOrderStatus.loading) { - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: () => Modular.to.navigate(ClientPaths.createOrder), - ), - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.search, - size: 64, - color: UiColors.iconInactive, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'No Vendors Available', - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - 'There are no staffing vendors associated with your account.', - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ], - ), - ); - } - - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: () => Modular.to.navigate(ClientPaths.createOrder), - ), - Expanded( - child: Stack( - children: [ - _OneTimeOrderForm(state: state), - if (state.status == OneTimeOrderStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: state.status == OneTimeOrderStatus.loading - ? labels.creating - : labels.create_order, - isLoading: state.status == OneTimeOrderStatus.loading, - onPressed: state.isValid - ? () => BlocProvider.of( - context, - ).add(const OneTimeOrderSubmitted()) - : null, - ), - ], - ), - ); - }, - ); - } -} - -class _OneTimeOrderForm extends StatelessWidget { - const _OneTimeOrderForm({required this.state}); - final OneTimeOrderState state; - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderOneTimeEn labels = - t.client_create_order.one_time; - - return ListView( - padding: const EdgeInsets.all(UiConstants.space5), - children: [ - Text( - labels.create_your_order, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderEventNameInput( - label: 'ORDER NAME', - value: state.eventName, - onChanged: (String value) => BlocProvider.of( - context, - ).add(OneTimeOrderEventNameChanged(value)), - ), - const SizedBox(height: UiConstants.space4), - - // Vendor Selection - Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: state.selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - BlocProvider.of( - context, - ).add(OneTimeOrderVendorChanged(vendor)); - } - }, - items: state.vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderDatePicker( - label: labels.date_label, - value: state.date, - onChanged: (DateTime date) => BlocProvider.of( - context, - ).add(OneTimeOrderDateChanged(date)), - ), - const SizedBox(height: UiConstants.space4), - - Text('HUB', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: state.selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OneTimeOrderHubOption? hub) { - if (hub != null) { - BlocProvider.of( - context, - ).add(OneTimeOrderHubChanged(hub)); - } - }, - items: state.hubs.map((OneTimeOrderHubOption hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space6), - - OneTimeOrderSectionHeader( - title: labels.positions_title, - actionLabel: labels.add_position, - onAction: () => BlocProvider.of( - context, - ).add(const OneTimeOrderPositionAdded()), - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...state.positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OneTimeOrderPosition position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: OneTimeOrderPositionCard( - index: index, - position: position, - isRemovable: state.positions.length > 1, - positionLabel: labels.positions_title, - roleLabel: labels.select_role, - workersLabel: labels.workers_label, - startLabel: labels.start_label, - endLabel: labels.end_label, - lunchLabel: labels.lunch_break_label, - roles: state.roles, - onUpdated: (OneTimeOrderPosition updated) { - BlocProvider.of( - context, - ).add(OneTimeOrderPositionUpdated(index, updated)); - }, - onRemoved: () { - BlocProvider.of( - context, - ).add(OneTimeOrderPositionRemoved(index)); - }, - ), - ); - }), - ], - ); - } -} - -class _BottomActionButton extends StatelessWidget { - const _BottomActionButton({ - required this.label, - required this.onPressed, - this.isLoading = false, - }); - final String label; - final VoidCallback? onPressed; - final bool isLoading; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - left: UiConstants.space5, - right: UiConstants.space5, - top: UiConstants.space5, - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, - ), - decoration: const BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.border)), - ), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: label, - onPressed: isLoading ? null : onPressed, - size: UiButtonSize.large, - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index 7d89f676..11f15feb 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -1,22 +1,19 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/home_repository_interface.dart'; -/// Implementation of [HomeRepositoryInterface] that delegates to [HomeRepositoryMock]. -/// -/// This implementation resides in the data layer and acts as a bridge between the -/// domain layer and the data source (in this case, a mock from data_connect). +/// Implementation of [HomeRepositoryInterface] that directly interacts with the Data Connect SDK. class HomeRepositoryImpl implements HomeRepositoryInterface { - /// Creates a [HomeRepositoryImpl]. - HomeRepositoryImpl(this._service); + HomeRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; + final dc.DataConnectService _service; @override Future getDashboardData() async { return _service.run(() async { final String businessId = await _service.getBusinessId(); - final DateTime now = DateTime.now(); final int daysFromMonday = now.weekday - DateTime.monday; final DateTime monday = DateTime( @@ -24,21 +21,12 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { now.month, now.day, ).subtract(Duration(days: daysFromMonday)); - final DateTime weekRangeStart = DateTime( - monday.year, - monday.month, - monday.day, + final DateTime weekRangeStart = monday; + final DateTime weekRangeEnd = monday.add( + const Duration(days: 13, hours: 23, minutes: 59, seconds: 59), ); - final DateTime weekRangeEnd = DateTime( - monday.year, - monday.month, - monday.day + 13, - 23, - 59, - 59, - 999, - ); - final fdc.QueryResult< + + final QueryResult< dc.GetCompletedShiftsByBusinessIdData, dc.GetCompletedShiftsByBusinessIdVariables > @@ -54,16 +42,15 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { double next7DaysSpending = 0.0; int weeklyShifts = 0; int next7DaysScheduled = 0; + for (final dc.GetCompletedShiftsByBusinessIdShifts shift in completedResult.data.shifts) { - final DateTime? shiftDate = shift.date?.toDateTime(); - if (shiftDate == null) { - continue; - } + final DateTime? shiftDate = _service.toDateTime(shift.date); + if (shiftDate == null) continue; + final int offset = shiftDate.difference(weekRangeStart).inDays; - if (offset < 0 || offset > 13) { - continue; - } + if (offset < 0 || offset > 13) continue; + final double cost = shift.cost ?? 0.0; if (offset <= 6) { weeklySpending += cost; @@ -75,17 +62,11 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = DateTime( - now.year, - now.month, - now.day, - 23, - 59, - 59, - 999, + final DateTime end = start.add( + const Duration(hours: 23, minutes: 59, seconds: 59), ); - final fdc.QueryResult< + final QueryResult< dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables > @@ -121,7 +102,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientBusinessSession? business = session?.business; - // If session data is available, return it immediately if (business != null) { return UserSessionData( businessName: business.businessName, @@ -130,32 +110,32 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } return await _service.run(() async { - // If session is not initialized, attempt to fetch business data to populate session final String businessId = await _service.getBusinessId(); - final fdc.QueryResult + final QueryResult businessResult = await _service.connector .getBusinessById(id: businessId) .execute(); - if (businessResult.data.business == null) { + final dc.GetBusinessByIdBusiness? b = businessResult.data.business; + if (b == null) { throw Exception('Business data not found for ID: $businessId'); } final dc.ClientSession updatedSession = dc.ClientSession( business: dc.ClientBusinessSession( - id: businessResult.data.business!.id, - businessName: businessResult.data.business?.businessName ?? '', - email: businessResult.data.business?.email ?? '', - city: businessResult.data.business?.city ?? '', - contactName: businessResult.data.business?.contactName ?? '', - companyLogoUrl: businessResult.data.business?.companyLogoUrl, + id: b.id, + businessName: b.businessName, + email: b.email ?? '', + city: b.city ?? '', + contactName: b.contactName ?? '', + companyLogoUrl: b.companyLogoUrl, ), ); dc.ClientSessionStore.instance.setSession(updatedSession); return UserSessionData( - businessName: businessResult.data.business!.businessName, - photoUrl: businessResult.data.business!.companyLogoUrl, + businessName: b.businessName, + photoUrl: b.companyLogoUrl, ); }); } @@ -164,38 +144,63 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { Future> getRecentReorders() async { return _service.run(() async { final String businessId = await _service.getBusinessId(); - final DateTime now = DateTime.now(); final DateTime start = now.subtract(const Duration(days: 30)); - final fdc.Timestamp startTimestamp = _service.toTimestamp(start); - final fdc.Timestamp endTimestamp = _service.toTimestamp(now); - final fdc.QueryResult< - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables + final QueryResult< + dc.ListCompletedOrdersByBusinessAndDateRangeData, + dc.ListCompletedOrdersByBusinessAndDateRangeVariables > result = await _service.connector - .listShiftRolesByBusinessDateRangeCompletedOrders( + .listCompletedOrdersByBusinessAndDateRange( businessId: businessId, - start: startTimestamp, - end: endTimestamp, + start: _service.toTimestamp(start), + end: _service.toTimestamp(now), ) .execute(); - return result.data.shiftRoles.map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, + return result.data.orders.map(( + dc.ListCompletedOrdersByBusinessAndDateRangeOrders order, ) { - final String location = - shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; - final String type = shiftRole.shift.order.orderType.stringValue; + final String title = + order.eventName ?? + (order.shifts_on_order.isNotEmpty + ? order.shifts_on_order[0].title + : 'Order'); + + final String location = order.shifts_on_order.isNotEmpty + ? (order.shifts_on_order[0].location ?? + order.shifts_on_order[0].locationAddress ?? + '') + : ''; + + int totalWorkers = 0; + double totalHours = 0; + double totalRate = 0; + int roleCount = 0; + + for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrder + shift + in order.shifts_on_order) { + for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrderShiftRolesOnShift + role + in shift.shiftRoles_on_shift) { + totalWorkers += role.count; + totalHours += role.hours ?? 0; + totalRate += role.role.costPerHour; + roleCount++; + } + } + return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + orderId: order.id, + title: title, location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, + totalCost: order.total ?? 0.0, + workers: totalWorkers, + type: order.orderType.stringValue, + hourlyRate: roleCount > 0 ? totalRate / roleCount : 0.0, + hours: totalHours, ); }).toList(); }); diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index cba07bba..7fef5b8e 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -37,7 +37,7 @@ class ClientHomeBloc extends Bloc ) async { emit(state.copyWith(status: ClientHomeStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { // Get session data final UserSessionData sessionData = await _getUserSessionDataUseCase(); diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart index 3af93fc3..0b7eb44b 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart @@ -1,22 +1,13 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; /// A widget that displays quick actions for the client. class ActionsWidget extends StatelessWidget { /// Creates an [ActionsWidget]. - const ActionsWidget({ - super.key, - required this.onRapidPressed, - required this.onCreateOrderPressed, - this.subtitle, - }); - - /// Callback when RAPID is pressed. - final VoidCallback onRapidPressed; - - /// Callback when Create Order is pressed. - final VoidCallback onCreateOrderPressed; + const ActionsWidget({super.key, this.subtitle}); /// Optional subtitle for the section. final String? subtitle; @@ -40,7 +31,7 @@ class ActionsWidget extends StatelessWidget { iconColor: UiColors.textError, textColor: UiColors.textError, subtitleColor: UiColors.textError.withValues(alpha: 0.8), - onTap: onRapidPressed, + onTap: () => Modular.to.toCreateOrderRapid(), ), ), Expanded( @@ -54,7 +45,7 @@ class ActionsWidget extends StatelessWidget { iconColor: UiColors.primary, textColor: UiColors.textPrimary, subtitleColor: UiColors.textSecondary, - onTap: onCreateOrderPressed, + onTap: () => Modular.to.toCreateOrder(), ), ), ], diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index 9c2931d7..bcfe0d31 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -26,7 +26,7 @@ class ClientHomeEditBanner extends StatelessWidget { builder: (BuildContext context, ClientHomeState state) { return AnimatedContainer( duration: const Duration(milliseconds: 300), - height: state.isEditMode ? 76 : 0, + height: state.isEditMode ? 80 : 0, clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -43,21 +43,23 @@ class ClientHomeEditBanner extends StatelessWidget { children: [ const Icon(UiIcons.edit, size: 16, color: UiColors.primary), const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - i18n.dashboard.edit_mode_active, - style: UiTypography.footnote1b.copyWith( - color: UiColors.primary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + i18n.dashboard.edit_mode_active, + style: UiTypography.footnote1b.copyWith( + color: UiColors.primary, + ), ), - ), - Text( - i18n.dashboard.drag_instruction, - style: UiTypography.footnote2r.textSecondary, - ), - ], + Text( + i18n.dashboard.drag_instruction, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ), UiButton.secondary( text: i18n.dashboard.reset, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart index 488a9bb3..296b04d8 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart @@ -9,14 +9,12 @@ import '../widgets/draggable_widget_wrapper.dart'; import '../widgets/live_activity_widget.dart'; import '../widgets/reorder_widget.dart'; import '../widgets/spending_widget.dart'; -import 'client_home_sheets.dart'; /// A widget that builds dashboard content based on widget ID. /// /// This widget encapsulates the logic for rendering different dashboard /// widgets based on their unique identifiers and current state. class DashboardWidgetBuilder extends StatelessWidget { - /// Creates a [DashboardWidgetBuilder]. const DashboardWidgetBuilder({ required this.id, @@ -24,6 +22,7 @@ class DashboardWidgetBuilder extends StatelessWidget { required this.isEditMode, super.key, }); + /// The unique identifier for the widget to build. final String id; @@ -62,39 +61,9 @@ class DashboardWidgetBuilder extends StatelessWidget { switch (id) { case 'actions': - return ActionsWidget( - onRapidPressed: () => Modular.to.toCreateOrderRapid(), - onCreateOrderPressed: () => Modular.to.toCreateOrder(), - subtitle: subtitle, - ); + return ActionsWidget(subtitle: subtitle); case 'reorder': - return ReorderWidget( - orders: state.reorderItems, - onReorderPressed: (Map data) { - ClientHomeSheets.showOrderFormSheet( - context, - data, - onSubmit: (Map submittedData) { - final String? dateStr = - submittedData['date']?.toString(); - if (dateStr == null || dateStr.isEmpty) { - return; - } - final DateTime? initialDate = DateTime.tryParse(dateStr); - if (initialDate == null) { - return; - } - Modular.to.navigate( - '/client-main/orders/', - arguments: { - 'initialDate': initialDate.toIso8601String(), - }, - ); - }, - ); - }, - subtitle: subtitle, - ); + return ReorderWidget(orders: state.reorderItems, subtitle: subtitle); case 'spending': return SpendingWidget( weeklySpending: state.dashboardData.weeklySpending, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index b3544e48..fb1da7d5 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -1,24 +1,18 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; /// A widget that allows clients to reorder recent shifts. class ReorderWidget extends StatelessWidget { - /// Creates a [ReorderWidget]. - const ReorderWidget({ - super.key, - required this.orders, - required this.onReorderPressed, - this.subtitle, - }); + const ReorderWidget({super.key, required this.orders, this.subtitle}); + /// Recent completed orders for reorder. final List orders; - /// Callback when a reorder button is pressed. - final Function(Map shiftData) onReorderPressed; - /// Optional subtitle for the section. final String? subtitle; @@ -55,8 +49,7 @@ class ReorderWidget extends StatelessWidget { const SizedBox(width: UiConstants.space3), itemBuilder: (BuildContext context, int index) { final ReorderItem order = recentOrders[index]; - final double totalCost = - order.hourlyRate * order.hours * order.workers; + final double totalCost = order.totalCost; return Container( width: 260, @@ -155,15 +148,17 @@ class ReorderWidget extends StatelessWidget { leadingIcon: UiIcons.zap, iconSize: 12, fullWidth: true, - onPressed: () => onReorderPressed({ - 'orderId': order.orderId, - 'title': order.title, - 'location': order.location, - 'hourlyRate': order.hourlyRate, - 'hours': order.hours, - 'workers': order.workers, - 'type': order.type, - }), + onPressed: () => + _handleReorderPressed(context, { + 'orderId': order.orderId, + 'title': order.title, + 'location': order.location, + 'hourlyRate': order.hourlyRate, + 'hours': order.hours, + 'workers': order.workers, + 'type': order.type, + 'totalCost': order.totalCost, + }), ), ], ), @@ -174,10 +169,34 @@ class ReorderWidget extends StatelessWidget { ], ); } + + void _handleReorderPressed(BuildContext context, Map data) { + // Override start date with today's date as requested + final Map populatedData = Map.from(data) + ..['startDate'] = DateTime.now(); + + final String? typeStr = populatedData['type']?.toString(); + if (typeStr == null || typeStr.isEmpty) { + return; + } + + final OrderType orderType = OrderType.fromString(typeStr); + switch (orderType) { + case OrderType.recurring: + Modular.to.toCreateOrderRecurring(arguments: populatedData); + break; + case OrderType.permanent: + Modular.to.toCreateOrderPermanent(arguments: populatedData); + break; + case OrderType.oneTime: + default: + Modular.to.toCreateOrderOneTime(arguments: populatedData); + break; + } + } } class _Badge extends StatelessWidget { - const _Badge({ required this.icon, required this.text, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart index 15bdac09..8bb83203 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart @@ -265,7 +265,7 @@ class _ShiftOrderFormSheetState extends State { .state(selectedHub.state) .street(selectedHub.street) .country(selectedHub.country) - .status(dc.ShiftStatus.PENDING) + .status(dc.ShiftStatus.OPEN) .workersNeeded(workersNeeded) .filled(0) .durationDays(1) @@ -651,9 +651,9 @@ class _ShiftOrderFormSheetState extends State { return Container( height: MediaQuery.of(context).size.height * 0.95, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: UiColors.bgPrimary, - borderRadius: const BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), + borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), ), child: Column( children: [ diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 1f7c0eb9..53fdb2e4 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -1,5 +1,6 @@ library; +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -8,9 +9,16 @@ import 'src/domain/repositories/hub_repository_interface.dart'; import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/create_hub_usecase.dart'; import 'src/domain/usecases/delete_hub_usecase.dart'; +import 'src/domain/usecases/get_cost_centers_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart'; +import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart'; +import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; +import 'src/presentation/blocs/hub_details/hub_details_bloc.dart'; import 'src/presentation/pages/client_hubs_page.dart'; +import 'src/presentation/pages/edit_hub_page.dart'; +import 'src/presentation/pages/hub_details_page.dart'; +import 'package:krow_domain/krow_domain.dart'; export 'src/presentation/pages/client_hubs_page.dart'; @@ -26,16 +34,55 @@ class ClientHubsModule extends Module { // UseCases i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(GetCostCentersUseCase.new); i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new); + i.addLazySingleton(UpdateHubUseCase.new); // BLoCs i.add(ClientHubsBloc.new); + i.add(EditHubBloc.new); + i.add(HubDetailsBloc.new); } @override void routes(RouteManager r) { - r.child(ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), child: (_) => const ClientHubsPage()); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), + child: (_) => const ClientHubsPage(), + ); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails), + child: (_) { + final Map data = r.args.data as Map; + return HubDetailsPage( + hub: data['hub'] as Hub, + bloc: Modular.get(), + ); + }, + ); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub), + transition: TransitionType.custom, + customTransition: CustomTransition( + opaque: false, + transitionBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition(opacity: animation, child: child); + }, + ), + child: (_) { + final Map data = r.args.data as Map; + return EditHubPage( + hub: data['hub'] as Hub?, + bloc: Modular.get(), + ); + }, + ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 91de3bdf..ac91ac28 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -1,38 +1,49 @@ -import 'dart:convert'; - -import 'package:firebase_auth/firebase_auth.dart' as firebase; -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:http/http.dart' as http; -import 'package:krow_core/core.dart'; +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_domain/krow_domain.dart' as domain; -import 'package:krow_domain/krow_domain.dart' - show - HubHasOrdersException, - BusinessNotFoundException, - NotAuthenticatedException; - +import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/hub_repository_interface.dart'; -/// Implementation of [HubRepositoryInterface] backed by Data Connect. +/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class HubRepositoryImpl implements HubRepositoryInterface { - HubRepositoryImpl({required dc.DataConnectService service}) - : _service = service; + HubRepositoryImpl({ + dc.HubsConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getHubsRepository(), + _service = service ?? dc.DataConnectService.instance; + final dc.HubsConnectorRepository _connectorRepository; final dc.DataConnectService _service; @override - Future> getHubs() async { + Future> getHubs() async { + final String businessId = await _service.getBusinessId(); + return _connectorRepository.getHubs(businessId: businessId); + } + + @override + Future> getCostCenters() async { return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = - await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - return _fetchHubsForTeam(teamId: teamId, businessId: business.id); + final result = await _service.connector.listTeamHudDepartments().execute(); + final Set seen = {}; + final List costCenters = []; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in result.data.teamHudDepartments) { + final String? cc = dep.costCenter; + if (cc != null && cc.isNotEmpty && !seen.contains(cc)) { + seen.add(cc); + costCenters.add(CostCenter(id: cc, name: dep.name, code: cc)); + } + } + return costCenters; }); } @override - Future createHub({ + Future createHub({ required String name, required String address, String? placeId, @@ -43,78 +54,29 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }) async { - return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = - await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty - ? null - : await _fetchPlaceAddress(placeId); - final String? cityValue = city ?? placeAddress?.city ?? business.city; - final String? stateValue = state ?? placeAddress?.state; - final String? streetValue = street ?? placeAddress?.street; - final String? countryValue = country ?? placeAddress?.country; - final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; - - final OperationResult - result = await _service.connector - .createTeamHub(teamId: teamId, hubName: name, address: address) - .placeId(placeId) - .latitude(latitude) - .longitude(longitude) - .city(cityValue?.isNotEmpty == true ? cityValue : '') - .state(stateValue) - .street(streetValue) - .country(countryValue) - .zipCode(zipCodeValue) - .execute(); - final String createdId = result.data.teamHub_insert.id; - - final List hubs = await _fetchHubsForTeam( - teamId: teamId, - businessId: business.id, - ); - domain.Hub? createdHub; - for (final domain.Hub hub in hubs) { - if (hub.id == createdId) { - createdHub = hub; - break; - } - } - return createdHub ?? - domain.Hub( - id: createdId, - businessId: business.id, - name: name, - address: address, - nfcTagId: null, - status: domain.HubStatus.active, - ); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.createHub( + businessId: businessId, + name: name, + address: address, + placeId: placeId, + latitude: latitude, + longitude: longitude, + city: city, + state: state, + street: street, + country: country, + zipCode: zipCode, + costCenterId: costCenterId, + ); } @override Future deleteHub(String id) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final QueryResult< - dc.ListOrdersByBusinessAndTeamHubData, - dc.ListOrdersByBusinessAndTeamHubVariables - > - result = await _service.connector - .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id) - .execute(); - - if (result.data.orders.isNotEmpty) { - throw HubHasOrdersException( - technicalMessage: 'Hub $id has ${result.data.orders.length} orders', - ); - } - - await _service.connector.deleteTeamHub(id: id).execute(); - }); + final String businessId = await _service.getBusinessId(); + return _connectorRepository.deleteHub(businessId: businessId, id: id); } @override @@ -124,225 +86,37 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } - Future - _getBusinessForCurrentUser() async { - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - final dc.ClientBusinessSession? cachedBusiness = session?.business; - if (cachedBusiness != null) { - return dc.GetBusinessesByUserIdBusinesses( - id: cachedBusiness.id, - businessName: cachedBusiness.businessName, - userId: _service.auth.currentUser?.uid ?? '', - rateGroup: const dc.Known( - dc.BusinessRateGroup.STANDARD, - ), - status: const dc.Known(dc.BusinessStatus.ACTIVE), - contactName: cachedBusiness.contactName, - companyLogoUrl: cachedBusiness.companyLogoUrl, - phone: null, - email: cachedBusiness.email, - hubBuilding: null, - address: null, - city: cachedBusiness.city, - area: null, - sector: null, - notes: null, - createdAt: null, - updatedAt: null, - ); - } - - final firebase.User? user = _service.auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'No Firebase user in currentUser', - ); - } - - final QueryResult< - dc.GetBusinessesByUserIdData, - dc.GetBusinessesByUserIdVariables - > - result = await _service.connector - .getBusinessesByUserId(userId: user.uid) - .execute(); - if (result.data.businesses.isEmpty) { - await _service.auth.signOut(); - throw BusinessNotFoundException( - technicalMessage: 'No business found for user ${user.uid}', - ); - } - - final dc.GetBusinessesByUserIdBusinesses business = - result.data.businesses.first; - if (session != null) { - dc.ClientSessionStore.instance.setSession( - dc.ClientSession( - business: dc.ClientBusinessSession( - id: business.id, - businessName: business.businessName, - email: business.email, - city: business.city, - contactName: business.contactName, - companyLogoUrl: business.companyLogoUrl, - ), - ), - ); - } - - return business; - } - - Future _getOrCreateTeamId( - dc.GetBusinessesByUserIdBusinesses business, - ) async { - final QueryResult - teamsResult = await _service.connector - .getTeamsByOwnerId(ownerId: business.id) - .execute(); - if (teamsResult.data.teams.isNotEmpty) { - return teamsResult.data.teams.first.id; - } - - final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector - .createTeam( - teamName: '${business.businessName} Team', - ownerId: business.id, - ownerName: business.contactName ?? '', - ownerRole: 'OWNER', - ); - if (business.email != null) { - createTeamBuilder.email(business.email); - } - - final OperationResult - createTeamResult = await createTeamBuilder.execute(); - final String teamId = createTeamResult.data.team_insert.id; - - return teamId; - } - - Future> _fetchHubsForTeam({ - required String teamId, - required String businessId, + @override + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, }) async { - final QueryResult< - dc.GetTeamHubsByTeamIdData, - dc.GetTeamHubsByTeamIdVariables - > - hubsResult = await _service.connector - .getTeamHubsByTeamId(teamId: teamId) - .execute(); - - return hubsResult.data.teamHubs - .map( - (dc.GetTeamHubsByTeamIdTeamHubs hub) => domain.Hub( - id: hub.id, - businessId: businessId, - name: hub.hubName, - address: hub.address, - nfcTagId: null, - status: hub.isActive - ? domain.HubStatus.active - : domain.HubStatus.inactive, - ), - ) - .toList(); - } - - Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async { - final Uri uri = Uri.https( - 'maps.googleapis.com', - '/maps/api/place/details/json', - { - 'place_id': placeId, - 'fields': 'address_component', - 'key': AppConfig.googleMapsApiKey, - }, + final String businessId = await _service.getBusinessId(); + return _connectorRepository.updateHub( + businessId: businessId, + id: id, + name: name, + address: address, + placeId: placeId, + latitude: latitude, + longitude: longitude, + city: city, + state: state, + street: street, + country: country, + zipCode: zipCode, + costCenterId: costCenterId, ); - try { - final http.Response response = await http.get(uri); - if (response.statusCode != 200) { - return null; - } - - final Map payload = - json.decode(response.body) as Map; - if (payload['status'] != 'OK') { - return null; - } - - final Map? result = - payload['result'] as Map?; - final List? components = - result?['address_components'] as List?; - if (components == null || components.isEmpty) { - return null; - } - - String? streetNumber; - String? route; - String? city; - String? state; - String? country; - String? zipCode; - - for (final dynamic entry in components) { - final Map component = entry as Map; - final List types = - component['types'] as List? ?? []; - final String? longName = component['long_name'] as String?; - final String? shortName = component['short_name'] as String?; - - if (types.contains('street_number')) { - streetNumber = longName; - } else if (types.contains('route')) { - route = longName; - } else if (types.contains('locality')) { - city = longName; - } else if (types.contains('postal_town')) { - city ??= longName; - } else if (types.contains('administrative_area_level_2')) { - city ??= longName; - } else if (types.contains('administrative_area_level_1')) { - state = shortName ?? longName; - } else if (types.contains('country')) { - country = shortName ?? longName; - } else if (types.contains('postal_code')) { - zipCode = longName; - } - } - - final String streetValue = [streetNumber, route] - .where((String? value) => value != null && value.isNotEmpty) - .join(' ') - .trim(); - - return _PlaceAddress( - street: streetValue.isEmpty == true ? null : streetValue, - city: city, - state: state, - country: country, - zipCode: zipCode, - ); - } catch (_) { - return null; - } } } -class _PlaceAddress { - const _PlaceAddress({ - this.street, - this.city, - this.state, - this.country, - this.zipCode, - }); - - final String? street; - final String? city; - final String? state; - final String? country; - final String? zipCode; -} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart index ad6199de..18e6a3fd 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart @@ -19,6 +19,7 @@ class CreateHubArguments extends UseCaseArgument { this.street, this.country, this.zipCode, + this.costCenterId, }); /// The name of the hub. final String name; @@ -34,6 +35,9 @@ class CreateHubArguments extends UseCaseArgument { final String? street; final String? country; final String? zipCode; + + /// The cost center of the hub. + final String? costCenterId; @override List get props => [ @@ -47,5 +51,6 @@ class CreateHubArguments extends UseCaseArgument { street, country, zipCode, + costCenterId, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 5580e6e4..14e97bf2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface { /// Returns a list of [Hub] entities. Future> getHubs(); + /// Fetches the list of available cost centers for the current business. + Future> getCostCenters(); + /// Creates a new hub. /// /// Takes the [name] and [address] of the new hub. @@ -26,6 +29,7 @@ abstract interface class HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Deletes a hub by its [id]. @@ -35,4 +39,22 @@ abstract interface class HubRepositoryInterface { /// /// Takes the [hubId] and the [nfcTagId] to be associated. Future assignNfcTag({required String hubId, required String nfcTagId}); + + /// Updates an existing hub by its [id]. + /// + /// All fields other than [id] are optional โ€” only supplied values are updated. + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + String? costCenterId, + }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart index 9c55ed30..550acd89 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart @@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase { street: arguments.street, country: arguments.country, zipCode: arguments.zipCode, + costCenterId: arguments.costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart new file mode 100644 index 00000000..32f9d895 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Usecase to fetch all available cost centers. +class GetCostCentersUseCase { + GetCostCentersUseCase({required HubRepositoryInterface repository}) + : _repository = repository; + + final HubRepositoryInterface _repository; + + Future> call() async { + return _repository.getCostCenters(); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart new file mode 100644 index 00000000..cbfdb799 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -0,0 +1,76 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/hub_repository_interface.dart'; + +/// Arguments for the UpdateHubUseCase. +class UpdateHubArguments extends UseCaseArgument { + const UpdateHubArguments({ + required this.id, + this.name, + this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + this.costCenterId, + }); + + final String id; + final String? name; + final String? address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + final String? costCenterId; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; +} + +/// Use case for updating an existing hub. +class UpdateHubUseCase implements UseCase { + UpdateHubUseCase(this.repository); + + final HubRepositoryInterface repository; + + @override + Future call(UpdateHubArguments params) { + return repository.updateHub( + id: params.id, + name: params.name, + address: params.address, + placeId: params.placeId, + latitude: params.latitude, + longitude: params.longitude, + city: params.city, + state: params.state, + street: params.street, + country: params.country, + zipCode: params.zipCode, + costCenterId: params.costCenterId, + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart index 2c2acb02..4bd08959 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -2,12 +2,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/assign_nfc_tag_arguments.dart'; -import '../../domain/arguments/create_hub_arguments.dart'; -import '../../domain/arguments/delete_hub_arguments.dart'; -import '../../domain/usecases/assign_nfc_tag_usecase.dart'; -import '../../domain/usecases/create_hub_usecase.dart'; -import '../../domain/usecases/delete_hub_usecase.dart'; import '../../domain/usecases/get_hubs_usecase.dart'; import 'client_hubs_event.dart'; import 'client_hubs_state.dart'; @@ -15,62 +9,29 @@ import 'client_hubs_state.dart'; /// BLoC responsible for managing the state of the Client Hubs feature. /// /// It orchestrates the flow between the UI and the domain layer by invoking -/// specific use cases for fetching, creating, deleting, and assigning tags to hubs. +/// specific use cases for fetching hubs. class ClientHubsBloc extends Bloc with BlocErrorHandler implements Disposable { - - ClientHubsBloc({ - required GetHubsUseCase getHubsUseCase, - required CreateHubUseCase createHubUseCase, - required DeleteHubUseCase deleteHubUseCase, - required AssignNfcTagUseCase assignNfcTagUseCase, - }) : _getHubsUseCase = getHubsUseCase, - _createHubUseCase = createHubUseCase, - _deleteHubUseCase = deleteHubUseCase, - _assignNfcTagUseCase = assignNfcTagUseCase, - super(const ClientHubsState()) { + ClientHubsBloc({required GetHubsUseCase getHubsUseCase}) + : _getHubsUseCase = getHubsUseCase, + super(const ClientHubsState()) { on(_onFetched); - on(_onAddRequested); - on(_onDeleteRequested); - on(_onNfcTagAssignRequested); on(_onMessageCleared); - on(_onAddDialogToggled); - on(_onIdentifyDialogToggled); } + final GetHubsUseCase _getHubsUseCase; - final CreateHubUseCase _createHubUseCase; - final DeleteHubUseCase _deleteHubUseCase; - final AssignNfcTagUseCase _assignNfcTagUseCase; - - void _onAddDialogToggled( - ClientHubsAddDialogToggled event, - Emitter emit, - ) { - emit(state.copyWith(showAddHubDialog: event.visible)); - } - - void _onIdentifyDialogToggled( - ClientHubsIdentifyDialogToggled event, - Emitter emit, - ) { - if (event.hub == null) { - emit(state.copyWith(clearHubToIdentify: true)); - } else { - emit(state.copyWith(hubToIdentify: event.hub)); - } - } Future _onFetched( ClientHubsFetched event, Emitter emit, ) async { emit(state.copyWith(status: ClientHubsStatus.loading)); - + await handleError( - emit: emit, + emit: emit.call, action: () async { - final List hubs = await _getHubsUseCase(); + final List hubs = await _getHubsUseCase.call(); emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs)); }, onError: (String errorKey) => state.copyWith( @@ -80,101 +41,6 @@ class ClientHubsBloc extends Bloc ); } - Future _onAddRequested( - ClientHubsAddRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit, - action: () async { - await _createHubUseCase( - CreateHubArguments( - name: event.name, - address: event.address, - placeId: event.placeId, - latitude: event.latitude, - longitude: event.longitude, - city: event.city, - state: event.state, - street: event.street, - country: event.country, - zipCode: event.zipCode, - ), - ); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'Hub created successfully', - showAddHubDialog: false, - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - - Future _onDeleteRequested( - ClientHubsDeleteRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit, - action: () async { - await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId)); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'Hub deleted successfully', - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - - Future _onNfcTagAssignRequested( - ClientHubsNfcTagAssignRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit, - action: () async { - await _assignNfcTagUseCase( - AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), - ); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'NFC tag assigned successfully', - clearHubToIdentify: true, - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - void _onMessageCleared( ClientHubsMessageCleared event, Emitter emit, @@ -184,8 +50,8 @@ class ClientHubsBloc extends Bloc clearErrorMessage: true, clearSuccessMessage: true, status: - state.status == ClientHubsStatus.actionSuccess || - state.status == ClientHubsStatus.actionFailure + state.status == ClientHubsStatus.success || + state.status == ClientHubsStatus.failure ? ClientHubsStatus.success : state.status, ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart index 9e539c8e..f329807b 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; /// Base class for all client hubs events. abstract class ClientHubsEvent extends Equatable { @@ -14,92 +13,7 @@ class ClientHubsFetched extends ClientHubsEvent { const ClientHubsFetched(); } -/// Event triggered to add a new hub. -class ClientHubsAddRequested extends ClientHubsEvent { - - const ClientHubsAddRequested({ - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - }); - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - - @override - List get props => [ - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - ]; -} - -/// Event triggered to delete a hub. -class ClientHubsDeleteRequested extends ClientHubsEvent { - - const ClientHubsDeleteRequested(this.hubId); - final String hubId; - - @override - List get props => [hubId]; -} - -/// Event triggered to assign an NFC tag to a hub. -class ClientHubsNfcTagAssignRequested extends ClientHubsEvent { - - const ClientHubsNfcTagAssignRequested({ - required this.hubId, - required this.nfcTagId, - }); - final String hubId; - final String nfcTagId; - - @override - List get props => [hubId, nfcTagId]; -} - /// Event triggered to clear any error or success messages. class ClientHubsMessageCleared extends ClientHubsEvent { const ClientHubsMessageCleared(); } - -/// Event triggered to toggle the visibility of the "Add Hub" dialog. -class ClientHubsAddDialogToggled extends ClientHubsEvent { - - const ClientHubsAddDialogToggled({required this.visible}); - final bool visible; - - @override - List get props => [visible]; -} - -/// Event triggered to toggle the visibility of the "Identify NFC" dialog. -class ClientHubsIdentifyDialogToggled extends ClientHubsEvent { - - const ClientHubsIdentifyDialogToggled({this.hub}); - final Hub? hub; - - @override - List get props => [hub]; -} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart index 1d1eea5d..8d9c0daa 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart @@ -2,47 +2,27 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; /// Enum representing the status of the client hubs state. -enum ClientHubsStatus { - initial, - loading, - success, - failure, - actionInProgress, - actionSuccess, - actionFailure, -} +enum ClientHubsStatus { initial, loading, success, failure } /// State class for the ClientHubs BLoC. class ClientHubsState extends Equatable { - const ClientHubsState({ this.status = ClientHubsStatus.initial, this.hubs = const [], this.errorMessage, this.successMessage, - this.showAddHubDialog = false, - this.hubToIdentify, }); + final ClientHubsStatus status; final List hubs; final String? errorMessage; final String? successMessage; - /// Whether the "Add Hub" dialog should be visible. - final bool showAddHubDialog; - - /// The hub currently being identified/assigned an NFC tag. - /// If null, the identification dialog is closed. - final Hub? hubToIdentify; - ClientHubsState copyWith({ ClientHubsStatus? status, List? hubs, String? errorMessage, String? successMessage, - bool? showAddHubDialog, - Hub? hubToIdentify, - bool clearHubToIdentify = false, bool clearErrorMessage = false, bool clearSuccessMessage = false, }) { @@ -55,10 +35,6 @@ class ClientHubsState extends Equatable { successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage), - showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog, - hubToIdentify: clearHubToIdentify - ? null - : (hubToIdentify ?? this.hubToIdentify), ); } @@ -68,7 +44,5 @@ class ClientHubsState extends Equatable { hubs, errorMessage, successMessage, - showAddHubDialog, - hubToIdentify, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart new file mode 100644 index 00000000..a455c0f3 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart @@ -0,0 +1,120 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../../domain/arguments/create_hub_arguments.dart'; +import '../../../domain/usecases/create_hub_usecase.dart'; +import '../../../domain/usecases/update_hub_usecase.dart'; +import '../../../domain/usecases/get_cost_centers_usecase.dart'; +import 'edit_hub_event.dart'; +import 'edit_hub_state.dart'; + +/// Bloc for creating and updating hubs. +class EditHubBloc extends Bloc + with BlocErrorHandler { + EditHubBloc({ + required CreateHubUseCase createHubUseCase, + required UpdateHubUseCase updateHubUseCase, + required GetCostCentersUseCase getCostCentersUseCase, + }) : _createHubUseCase = createHubUseCase, + _updateHubUseCase = updateHubUseCase, + _getCostCentersUseCase = getCostCentersUseCase, + super(const EditHubState()) { + on(_onCostCentersLoadRequested); + on(_onAddRequested); + on(_onUpdateRequested); + } + + final CreateHubUseCase _createHubUseCase; + final UpdateHubUseCase _updateHubUseCase; + final GetCostCentersUseCase _getCostCentersUseCase; + + Future _onCostCentersLoadRequested( + EditHubCostCentersLoadRequested event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + final List costCenters = await _getCostCentersUseCase.call(); + emit(state.copyWith(costCenters: costCenters)); + }, + onError: (String errorKey) => state.copyWith( + status: EditHubStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onAddRequested( + EditHubAddRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditHubStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _createHubUseCase.call( + CreateHubArguments( + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + costCenterId: event.costCenterId, + ), + ); + emit( + state.copyWith( + status: EditHubStatus.success, + successKey: 'created', + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey), + ); + } + + Future _onUpdateRequested( + EditHubUpdateRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditHubStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _updateHubUseCase.call( + UpdateHubArguments( + id: event.id, + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + costCenterId: event.costCenterId, + ), + ); + emit( + state.copyWith( + status: EditHubStatus.success, + successKey: 'updated', + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart new file mode 100644 index 00000000..38e25de0 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart @@ -0,0 +1,105 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all edit hub events. +abstract class EditHubEvent extends Equatable { + const EditHubEvent(); + + @override + List get props => []; +} + +/// Event triggered to load all available cost centers. +class EditHubCostCentersLoadRequested extends EditHubEvent { + const EditHubCostCentersLoadRequested(); +} + +/// Event triggered to add a new hub. +class EditHubAddRequested extends EditHubEvent { + const EditHubAddRequested({ + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + this.costCenterId, + }); + + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + final String? costCenterId; + + @override + List get props => [ + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; +} + +/// Event triggered to update an existing hub. +class EditHubUpdateRequested extends EditHubEvent { + const EditHubUpdateRequested({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + this.costCenterId, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + final String? costCenterId; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart new file mode 100644 index 00000000..2c59b055 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Status of the edit hub operation. +enum EditHubStatus { + /// Initial state. + initial, + + /// Operation in progress. + loading, + + /// Operation succeeded. + success, + + /// Operation failed. + failure, +} + +/// State for the edit hub operation. +class EditHubState extends Equatable { + const EditHubState({ + this.status = EditHubStatus.initial, + this.errorMessage, + this.successMessage, + this.successKey, + this.costCenters = const [], + }); + + /// The status of the operation. + final EditHubStatus status; + + /// The error message if the operation failed. + final String? errorMessage; + + /// The success message if the operation succeeded. + final String? successMessage; + + /// Localization key for success message: 'created' | 'updated'. + final String? successKey; + + /// Available cost centers for selection. + final List costCenters; + + /// Create a copy of this state with the given fields replaced. + EditHubState copyWith({ + EditHubStatus? status, + String? errorMessage, + String? successMessage, + String? successKey, + List? costCenters, + }) { + return EditHubState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, + costCenters: costCenters ?? this.costCenters, + ); + } + + @override + List get props => [ + status, + errorMessage, + successMessage, + successKey, + costCenters, + ]; +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart new file mode 100644 index 00000000..4b91b0de --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart @@ -0,0 +1,75 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import '../../../domain/arguments/assign_nfc_tag_arguments.dart'; +import '../../../domain/arguments/delete_hub_arguments.dart'; +import '../../../domain/usecases/assign_nfc_tag_usecase.dart'; +import '../../../domain/usecases/delete_hub_usecase.dart'; +import 'hub_details_event.dart'; +import 'hub_details_state.dart'; + +/// Bloc for managing hub details and operations like delete and NFC assignment. +class HubDetailsBloc extends Bloc + with BlocErrorHandler { + HubDetailsBloc({ + required DeleteHubUseCase deleteHubUseCase, + required AssignNfcTagUseCase assignNfcTagUseCase, + }) : _deleteHubUseCase = deleteHubUseCase, + _assignNfcTagUseCase = assignNfcTagUseCase, + super(const HubDetailsState()) { + on(_onDeleteRequested); + on(_onNfcTagAssignRequested); + } + + final DeleteHubUseCase _deleteHubUseCase; + final AssignNfcTagUseCase _assignNfcTagUseCase; + + Future _onDeleteRequested( + HubDetailsDeleteRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: HubDetailsStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id)); + emit( + state.copyWith( + status: HubDetailsStatus.deleted, + successKey: 'deleted', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: HubDetailsStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onNfcTagAssignRequested( + HubDetailsNfcTagAssignRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: HubDetailsStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _assignNfcTagUseCase.call( + AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), + ); + emit( + state.copyWith( + status: HubDetailsStatus.success, + successMessage: 'NFC tag assigned successfully', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: HubDetailsStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart new file mode 100644 index 00000000..5c23da0b --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all hub details events. +abstract class HubDetailsEvent extends Equatable { + const HubDetailsEvent(); + + @override + List get props => []; +} + +/// Event triggered to delete a hub. +class HubDetailsDeleteRequested extends HubDetailsEvent { + const HubDetailsDeleteRequested(this.id); + final String id; + + @override + List get props => [id]; +} + +/// Event triggered to assign an NFC tag to a hub. +class HubDetailsNfcTagAssignRequested extends HubDetailsEvent { + const HubDetailsNfcTagAssignRequested({ + required this.hubId, + required this.nfcTagId, + }); + + final String hubId; + final String nfcTagId; + + @override + List get props => [hubId, nfcTagId]; +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart new file mode 100644 index 00000000..17ef70f8 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart @@ -0,0 +1,59 @@ +import 'package:equatable/equatable.dart'; + +/// Status of the hub details operation. +enum HubDetailsStatus { + /// Initial state. + initial, + + /// Operation in progress. + loading, + + /// Operation succeeded. + success, + + /// Operation failed. + failure, + + /// Hub was deleted. + deleted, +} + +/// State for the hub details operation. +class HubDetailsState extends Equatable { + const HubDetailsState({ + this.status = HubDetailsStatus.initial, + this.errorMessage, + this.successMessage, + this.successKey, + }); + + /// The status of the operation. + final HubDetailsStatus status; + + /// The error message if the operation failed. + final String? errorMessage; + + /// The success message if the operation succeeded. + final String? successMessage; + + /// Localization key for success message: 'deleted'. + final String? successKey; + + /// Create a copy of this state with the given fields replaced. + HubDetailsState copyWith({ + HubDetailsStatus? status, + String? errorMessage, + String? successMessage, + String? successKey, + }) { + return HubDetailsState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, + ); + } + + @override + List get props => [status, errorMessage, successMessage, successKey]; +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index c8fdffed..25772bc2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -8,11 +8,10 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/client_hubs_bloc.dart'; import '../blocs/client_hubs_event.dart'; import '../blocs/client_hubs_state.dart'; -import '../widgets/add_hub_dialog.dart'; + import '../widgets/hub_card.dart'; import '../widgets/hub_empty_state.dart'; import '../widgets/hub_info_card.dart'; -import '../widgets/identify_nfc_dialog.dart'; /// The main page for the client hubs feature. /// @@ -43,7 +42,8 @@ class ClientHubsPage extends StatelessWidget { context, ).add(const ClientHubsMessageCleared()); } - if (state.successMessage != null && state.successMessage!.isNotEmpty) { + if (state.successMessage != null && + state.successMessage!.isNotEmpty) { UiSnackbar.show( context, message: state.successMessage!, @@ -57,105 +57,54 @@ class ClientHubsPage extends StatelessWidget { builder: (BuildContext context, ClientHubsState state) { return Scaffold( backgroundColor: UiColors.bgMenu, - floatingActionButton: FloatingActionButton( - onPressed: () => BlocProvider.of( - context, - ).add(const ClientHubsAddDialogToggled(visible: true)), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - child: const Icon(UiIcons.add), - ), - body: Stack( - children: [ - CustomScrollView( - slivers: [ - _buildAppBar(context), - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - vertical: UiConstants.space5, - ).copyWith(bottom: 100), - sliver: SliverList( - delegate: SliverChildListDelegate([ - if (state.status == ClientHubsStatus.loading) - const Center(child: CircularProgressIndicator()) - else if (state.hubs.isEmpty) - HubEmptyState( - onAddPressed: () => - BlocProvider.of(context).add( - const ClientHubsAddDialogToggled( - visible: true, - ), - ), - ) - else ...[ - ...state.hubs.map( - (Hub hub) => HubCard( - hub: hub, - onNfcPressed: () => - BlocProvider.of( - context, - ).add( - ClientHubsIdentifyDialogToggled(hub: hub), - ), - onDeletePressed: () => _confirmDeleteHub( - context, - hub, - ), - ), - ), - ], - const SizedBox(height: UiConstants.space5), - const HubInfoCard(), - ]), + body: CustomScrollView( + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space5, + ).copyWith(bottom: 100), + sliver: SliverList( + delegate: SliverChildListDelegate([ + const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space5), + child: HubInfoCard(), ), - ), - ], + + if (state.status == ClientHubsStatus.loading) + const Center(child: CircularProgressIndicator()) + else if (state.hubs.isEmpty) + HubEmptyState( + onAddPressed: () async { + final bool? success = await Modular.to.toEditHub(); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + ) + else ...[ + ...state.hubs.map( + (Hub hub) => HubCard( + hub: hub, + onTap: () async { + final bool? success = await Modular.to + .toHubDetails(hub); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + ), + ), + ], + const SizedBox(height: UiConstants.space5), + ]), + ), ), - if (state.showAddHubDialog) - AddHubDialog( - onCreate: ( - String name, - String address, { - String? placeId, - double? latitude, - double? longitude, - }) { - BlocProvider.of(context).add( - ClientHubsAddRequested( - name: name, - address: address, - placeId: placeId, - latitude: latitude, - longitude: longitude, - ), - ); - }, - onCancel: () => BlocProvider.of( - context, - ).add(const ClientHubsAddDialogToggled(visible: false)), - ), - if (state.hubToIdentify != null) - IdentifyNfcDialog( - hub: state.hubToIdentify!, - onAssign: (String tagId) { - BlocProvider.of(context).add( - ClientHubsNfcTagAssignRequested( - hubId: state.hubToIdentify!.id, - nfcTagId: tagId, - ), - ); - }, - onCancel: () => BlocProvider.of( - context, - ).add(const ClientHubsIdentifyDialogToggled()), - ), - if (state.status == ClientHubsStatus.actionInProgress) - Container( - color: UiColors.black.withValues(alpha: 0.1), - child: const Center(child: CircularProgressIndicator()), - ), ], ), ); @@ -166,7 +115,7 @@ class ClientHubsPage extends StatelessWidget { Widget _buildAppBar(BuildContext context) { return SliverAppBar( - backgroundColor: UiColors.foreground, // Dark Slate equivalent + backgroundColor: UiColors.foreground, automaticallyImplyLeading: false, expandedHeight: 140, pinned: true, @@ -202,20 +151,35 @@ class ClientHubsPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_hubs.title, - style: UiTypography.headline1m.white, - ), - Text( - t.client_hubs.subtitle, - style: UiTypography.body2r.copyWith( - color: UiColors.switchInactive, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.title, + style: UiTypography.headline1m.white, ), - ), - ], + Text( + t.client_hubs.subtitle, + style: UiTypography.body2r.copyWith( + color: UiColors.switchInactive, + ), + ), + ], + ), + ), + UiButton.primary( + onPressed: () async { + final bool? success = await Modular.to.toEditHub(); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + text: t.client_hubs.add_hub, + leadingIcon: UiIcons.add, + size: UiButtonSize.small, ), ], ), @@ -225,51 +189,4 @@ class ClientHubsPage extends StatelessWidget { ), ); } - - Future _confirmDeleteHub(BuildContext context, Hub hub) async { - final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name; - return showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(t.client_hubs.delete_dialog.title), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.client_hubs.delete_dialog.message(hubName: hubName)), - const SizedBox(height: UiConstants.space2), - Text(t.client_hubs.delete_dialog.undo_warning), - const SizedBox(height: UiConstants.space2), - Text( - t.client_hubs.delete_dialog.dependency_warning, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Modular.to.pop(), - child: Text(t.client_hubs.delete_dialog.cancel), - ), - TextButton( - onPressed: () { - BlocProvider.of( - context, - ).add(ClientHubsDeleteRequested(hub.id)); - Modular.to.pop(); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(t.client_hubs.delete_dialog.delete), - ), - ], - ); - }, - ); - } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart new file mode 100644 index 00000000..8bc8373e --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -0,0 +1,132 @@ +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_domain/krow_domain.dart'; + +import '../blocs/edit_hub/edit_hub_bloc.dart'; +import '../blocs/edit_hub/edit_hub_event.dart'; +import '../blocs/edit_hub/edit_hub_state.dart'; +import '../widgets/hub_form_dialog.dart'; + +/// A wrapper page that shows the hub form in a modal-style layout. +class EditHubPage extends StatefulWidget { + const EditHubPage({this.hub, required this.bloc, super.key}); + + final Hub? hub; + final EditHubBloc bloc; + + @override + State createState() => _EditHubPageState(); +} + +class _EditHubPageState extends State { + @override + void initState() { + super.initState(); + // Load available cost centers + widget.bloc.add(const EditHubCostCentersLoadRequested()); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: BlocListener( + listenWhen: (EditHubState prev, EditHubState curr) => + prev.status != curr.status || prev.successKey != curr.successKey, + listener: (BuildContext context, EditHubState state) { + if (state.status == EditHubStatus.success && + state.successKey != null) { + final String message = state.successKey == 'created' + ? t.client_hubs.edit_hub.created_success + : t.client_hubs.edit_hub.updated_success; + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.success, + ); + Modular.to.pop(true); + } + if (state.status == EditHubStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: state.errorMessage!, + type: UiSnackbarType.error, + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, EditHubState state) { + final bool isSaving = state.status == EditHubStatus.loading; + + return Scaffold( + backgroundColor: UiColors.bgOverlay, + body: Stack( + children: [ + // Tap background to dismiss + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container(color: Colors.transparent), + ), + + // Dialog-style content centered + Align( + alignment: Alignment.center, + child: HubFormDialog( + hub: widget.hub, + costCenters: state.costCenters, + onCancel: () => Modular.to.pop(), + onSave: ({ + required String name, + required String address, + String? costCenterId, + String? placeId, + double? latitude, + double? longitude, + }) { + if (widget.hub == null) { + widget.bloc.add( + EditHubAddRequested( + name: name, + address: address, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } else { + widget.bloc.add( + EditHubUpdateRequested( + id: widget.hub!.id, + name: name, + address: address, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } + }, + ), + ), + + // Global loading overlay if saving + if (isSaving) + Container( + color: UiColors.black.withValues(alpha: 0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart new file mode 100644 index 00000000..16861eb5 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -0,0 +1,146 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../blocs/hub_details/hub_details_bloc.dart'; +import '../blocs/hub_details/hub_details_event.dart'; +import '../blocs/hub_details/hub_details_state.dart'; +import '../widgets/hub_details/hub_details_bottom_actions.dart'; +import '../widgets/hub_details/hub_details_header.dart'; +import '../widgets/hub_details/hub_details_item.dart'; + +/// A read-only details page for a single [Hub]. +/// +/// Shows hub name, address, and NFC tag assignment. +class HubDetailsPage extends StatelessWidget { + const HubDetailsPage({required this.hub, required this.bloc, super.key}); + + final Hub hub; + final HubDetailsBloc bloc; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocListener( + listener: (BuildContext context, HubDetailsState state) { + if (state.status == HubDetailsStatus.deleted) { + final String message = state.successKey == 'deleted' + ? t.client_hubs.hub_details.deleted_success + : (state.successMessage ?? t.client_hubs.hub_details.deleted_success); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.success, + ); + Modular.to.pop(true); // Return true to indicate change + } + if (state.status == HubDetailsStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: state.errorMessage!, + type: UiSnackbarType.error, + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, HubDetailsState state) { + final bool isLoading = state.status == HubDetailsStatus.loading; + + return Scaffold( + appBar: const UiAppBar(showBackButton: true), + bottomNavigationBar: HubDetailsBottomActions( + isLoading: isLoading, + onDelete: () => _confirmDeleteHub(context), + onEdit: () => _navigateToEditPage(context), + ), + backgroundColor: UiColors.bgMenu, + body: Stack( + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + HubDetailsHeader(hub: hub), + const Divider(height: 1, thickness: 0.5), + + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HubDetailsItem( + label: t.client_hubs.hub_details.nfc_label, + value: + hub.nfcTagId ?? + t.client_hubs.hub_details.nfc_not_assigned, + icon: UiIcons.nfc, + isHighlight: hub.nfcTagId != null, + ), + const SizedBox(height: UiConstants.space4), + HubDetailsItem( + label: t.client_hubs.hub_details.cost_center_label, + value: hub.costCenter != null + ? '${hub.costCenter!.name} (${hub.costCenter!.code})' + : t.client_hubs.hub_details.cost_center_none, + icon: UiIcons.bank, // Using bank icon for cost center + isHighlight: hub.costCenter != null, + ), + ], + ), + ), + ], + ), + ), + if (isLoading) + Container( + color: UiColors.black.withValues(alpha: 0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Future _navigateToEditPage(BuildContext context) async { + final bool? saved = await Modular.to.toEditHub(hub: hub); + if (saved == true && context.mounted) { + Modular.to.pop(true); // Return true to indicate change + } + } + + Future _confirmDeleteHub(BuildContext context) async { + final bool? confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(t.client_hubs.delete_dialog.title), + content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)), + actions: [ + UiButton.text( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.client_hubs.delete_dialog.cancel), + ), + UiButton.text( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: UiColors.destructive), + child: Text(t.client_hubs.delete_dialog.delete), + ), + ], + ), + ); + + if (confirm == true) { + bloc.add(HubDetailsDeleteRequested(hub.id)); + } + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart deleted file mode 100644 index 8c59e977..00000000 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:google_places_flutter/model/prediction.dart'; - -import 'hub_address_autocomplete.dart'; - -/// A dialog for adding a new hub. -class AddHubDialog extends StatefulWidget { - - /// Creates an [AddHubDialog]. - const AddHubDialog({ - required this.onCreate, - required this.onCancel, - super.key, - }); - /// Callback when the "Create Hub" button is pressed. - final void Function( - String name, - String address, { - String? placeId, - double? latitude, - double? longitude, - }) onCreate; - - /// Callback when the dialog is cancelled. - final VoidCallback onCancel; - - @override - State createState() => _AddHubDialogState(); -} - -class _AddHubDialogState extends State { - late final TextEditingController _nameController; - late final TextEditingController _addressController; - late final FocusNode _addressFocusNode; - Prediction? _selectedPrediction; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(); - _addressController = TextEditingController(); - _addressFocusNode = FocusNode(); - } - - @override - void dispose() { - _nameController.dispose(); - _addressController.dispose(); - _addressFocusNode.dispose(); - super.dispose(); - } - - final GlobalKey _formKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Container( - color: UiColors.bgOverlay, - child: Center( - child: SingleChildScrollView( - child: Container( - width: MediaQuery.of(context).size.width * 0.9, - padding: const EdgeInsets.all(UiConstants.space5), - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow(color: UiColors.popupShadow, blurRadius: 20), - ], - ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.client_hubs.add_hub_dialog.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space5), - _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), - TextFormField( - controller: _nameController, - style: UiTypography.body1r.textPrimary, - validator: (String? value) { - if (value == null || value.trim().isEmpty) { - return 'Name is required'; - } - return null; - }, - decoration: _buildInputDecoration( - t.client_hubs.add_hub_dialog.name_hint, - ), - ), - const SizedBox(height: UiConstants.space4), - _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), - // Assuming HubAddressAutocomplete is a custom widget wrapper. - // If it doesn't expose a validator, we might need to modify it or manually check _addressController. - // For now, let's just make sure we validate name. Address is tricky if it's a wrapper. - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.add_hub_dialog.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, - ), - const SizedBox(height: UiConstants.space8), - Row( - children: [ - Expanded( - child: UiButton.secondary( - onPressed: widget.onCancel, - text: t.common.cancel, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: UiButton.primary( - onPressed: () { - if (_formKey.currentState!.validate()) { - // Manually check address if needed, or assume manual entry is ok. - if (_addressController.text.trim().isEmpty) { - // Show manual error or scaffold - UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); - return; - } - - widget.onCreate( - _nameController.text, - _addressController.text, - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse( - _selectedPrediction?.lat ?? '', - ), - longitude: double.tryParse( - _selectedPrediction?.lng ?? '', - ), - ); - } - }, - text: t.client_hubs.add_hub_dialog.create_button, - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildFieldLabel(String label) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Text(label, style: UiTypography.body2m.textPrimary), - ); - } - - InputDecoration _buildInputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: UiTypography.body2r.textPlaceholder, - filled: true, - fillColor: UiColors.input, - contentPadding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.ring, width: 2), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart new file mode 100644 index 00000000..7cd617a2 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A simple field label widget for the edit hub page. +class EditHubFieldLabel extends StatelessWidget { + const EditHubFieldLabel(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(text, style: UiTypography.body2m.textPrimary), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart new file mode 100644 index 00000000..3a6e24f6 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart @@ -0,0 +1,212 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../hub_address_autocomplete.dart'; +import 'edit_hub_field_label.dart'; + +/// The form section for adding or editing a hub. +class EditHubFormSection extends StatelessWidget { + const EditHubFormSection({ + required this.formKey, + required this.nameController, + required this.addressController, + required this.addressFocusNode, + required this.onAddressSelected, + required this.onSave, + this.costCenters = const [], + this.selectedCostCenterId, + required this.onCostCenterChanged, + this.isSaving = false, + this.isEdit = false, + super.key, + }); + + final GlobalKey formKey; + final TextEditingController nameController; + final TextEditingController addressController; + final FocusNode addressFocusNode; + final ValueChanged onAddressSelected; + final VoidCallback onSave; + final List costCenters; + final String? selectedCostCenterId; + final ValueChanged onCostCenterChanged; + final bool isSaving; + final bool isEdit; + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // โ”€โ”€ Name field โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + EditHubFieldLabel(t.client_hubs.edit_hub.name_label), + TextFormField( + controller: nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return t.client_hubs.edit_hub.name_required; + } + return null; + }, + decoration: _inputDecoration(t.client_hubs.edit_hub.name_hint), + ), + + const SizedBox(height: UiConstants.space4), + + // โ”€โ”€ Address field โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + EditHubFieldLabel(t.client_hubs.edit_hub.address_label), + HubAddressAutocomplete( + controller: addressController, + hintText: t.client_hubs.edit_hub.address_hint, + focusNode: addressFocusNode, + onSelected: onAddressSelected, + ), + + const SizedBox(height: UiConstants.space4), + + EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label), + InkWell( + onTap: () => _showCostCenterSelector(context), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.input, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: selectedCostCenterId != null + ? UiColors.ring + : UiColors.border, + width: selectedCostCenterId != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedCostCenterId != null + ? _getCostCenterName(selectedCostCenterId!) + : t.client_hubs.edit_hub.cost_center_hint, + style: selectedCostCenterId != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + + const SizedBox(height: UiConstants.space8), + + // โ”€โ”€ Save button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + UiButton.primary( + onPressed: isSaving ? null : onSave, + text: isEdit + ? t.client_hubs.edit_hub.save_button + : t.client_hubs.add_hub_dialog.create_button, + ), + + const SizedBox(height: 40), + ], + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } + + String _getCostCenterName(String id) { + try { + final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id); + return cc.code != null ? '${cc.name} (${cc.code})' : cc.name; + } catch (_) { + return id; + } + } + + Future _showCostCenterSelector(BuildContext context) async { + final CostCenter? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_hubs.edit_hub.cost_center_label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child : costCenters.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.edit_hub.cost_centers_empty), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: costCenters.length, + itemBuilder: (BuildContext context, int index) { + final CostCenter cc = costCenters[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(cc.name, style: UiTypography.body1m.textPrimary), + subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null, + onTap: () => Navigator.of(context).pop(cc), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + onCostCenterChanged(selected.id); + } + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index 66f14d11..ee196446 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget { required this.controller, required this.hintText, this.focusNode, + this.decoration, this.onSelected, super.key, }); @@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget { final TextEditingController controller; final String hintText; final FocusNode? focusNode; + final InputDecoration? decoration; final void Function(Prediction prediction)? onSelected; @override @@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget { return GooglePlaceAutoCompleteTextField( textEditingController: controller, focusNode: focusNode, + inputDecoration: decoration ?? const InputDecoration(), googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, countries: HubsConstants.supportedCountries, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart index 812be35b..eb6b1aba 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart @@ -5,115 +5,95 @@ import 'package:core_localization/core_localization.dart'; /// A card displaying information about a single hub. class HubCard extends StatelessWidget { - /// Creates a [HubCard]. - const HubCard({ - required this.hub, - required this.onNfcPressed, - required this.onDeletePressed, - super.key, - }); + const HubCard({required this.hub, required this.onTap, super.key}); + /// The hub to display. final Hub hub; - /// Callback when the NFC button is pressed. - final VoidCallback onNfcPressed; - - /// Callback when the delete button is pressed. - final VoidCallback onDeletePressed; + /// Callback when the card is tapped. + final VoidCallback onTap; @override Widget build(BuildContext context) { final bool hasNfc = hub.nfcTagId != null; - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow( - color: UiColors.popupShadow, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Row( - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: UiColors.tagInProgress, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: UiColors.tagInProgress, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + hasNfc ? UiIcons.success : UiIcons.nfc, + color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird, + size: 24, + ), ), - child: Icon( - hasNfc ? UiIcons.success : UiIcons.nfc, - color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird, - size: 24, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(hub.name, style: UiTypography.body1b.textPrimary), - if (hub.address.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: UiConstants.space1), - child: Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 12, - color: UiColors.iconThird, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - hub.address, - style: UiTypography.footnote1r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(hub.name, style: UiTypography.body1b.textPrimary), + if (hub.address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + UiIcons.mapPin, + size: 12, + color: UiColors.iconThird, ), - ), - ], - ), - ), - if (hasNfc) - Padding( - padding: const EdgeInsets.only(top: UiConstants.space1), - child: Text( - t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), - style: UiTypography.footnote1b.copyWith( - color: UiColors.textSuccess, - fontFamily: 'monospace', + const SizedBox(width: UiConstants.space1), + Flexible( + child: Text( + hub.address, + style: UiTypography.footnote1r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), - ), - ], - ), - ), - Row( - children: [ - IconButton( - onPressed: onDeletePressed, - icon: const Icon( - UiIcons.delete, - color: UiColors.destructive, - size: 20, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - splashRadius: 20, + if (hasNfc) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Text( + t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), + style: UiTypography.footnote1b.copyWith( + color: UiColors.textSuccess, + fontFamily: 'monospace', + ), + ), + ), + ], ), - ], - ), - ], + ), + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), ), ), ); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart new file mode 100644 index 00000000..d109c6bc --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart @@ -0,0 +1,55 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Bottom action buttons for the hub details page. +class HubDetailsBottomActions extends StatelessWidget { + const HubDetailsBottomActions({ + required this.onDelete, + required this.onEdit, + this.isLoading = false, + super.key, + }); + + final VoidCallback onDelete; + final VoidCallback onEdit; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1, thickness: 0.5), + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: isLoading ? null : onDelete, + text: t.common.delete, + leadingIcon: UiIcons.delete, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + side: const BorderSide(color: UiColors.destructive), + ), + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: UiButton.secondary( + onPressed: isLoading ? null : onEdit, + text: t.client_hubs.hub_details.edit_button, + leadingIcon: UiIcons.edit, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart new file mode 100644 index 00000000..ccf670ed --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Header widget for the hub details page. +class HubDetailsHeader extends StatelessWidget { + const HubDetailsHeader({required this.hub, super.key}); + + final Hub hub; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + spacing: UiConstants.space1, + children: [ + Text(hub.name, style: UiTypography.headline1b.textPrimary), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.textSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + hub.address, + style: UiTypography.body2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart new file mode 100644 index 00000000..9a087669 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart @@ -0,0 +1,59 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A reusable detail item for the hub details page. +class HubDetailsItem extends StatelessWidget { + const HubDetailsItem({ + required this.label, + required this.value, + required this.icon, + this.isHighlight = false, + super.key, + }); + + final String label; + final String value; + final IconData icon; + final bool isHighlight; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: isHighlight + ? UiColors.tagInProgress + : UiColors.bgInputField, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + icon, + color: isHighlight ? UiColors.iconSuccess : UiColors.iconThird, + size: 20, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), + const SizedBox(height: UiConstants.space1), + Text(value, style: UiTypography.body1m.textPrimary), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart new file mode 100644 index 00000000..25d5f4b0 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart @@ -0,0 +1,350 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'hub_address_autocomplete.dart'; +import 'edit_hub/edit_hub_field_label.dart'; + +/// A bottom sheet dialog for adding or editing a hub. +class HubFormDialog extends StatefulWidget { + /// Creates a [HubFormDialog]. + const HubFormDialog({ + required this.onSave, + required this.onCancel, + this.hub, + this.costCenters = const [], + super.key, + }); + + /// The hub to edit. If null, a new hub is created. + final Hub? hub; + + /// Available cost centers for selection. + final List costCenters; + + /// Callback when the "Save" button is pressed. + final void Function({ + required String name, + required String address, + String? costCenterId, + String? placeId, + double? latitude, + double? longitude, + }) onSave; + + /// Callback when the dialog is cancelled. + final VoidCallback onCancel; + + @override + State createState() => _HubFormDialogState(); +} + +class _HubFormDialogState extends State { + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final FocusNode _addressFocusNode; + String? _selectedCostCenterId; + Prediction? _selectedPrediction; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.hub?.name); + _addressController = TextEditingController(text: widget.hub?.address); + _addressFocusNode = FocusNode(); + _selectedCostCenterId = widget.hub?.costCenter?.id; + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _addressFocusNode.dispose(); + super.dispose(); + } + + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final bool isEditing = widget.hub != null; + final String title = isEditing + ? t.client_hubs.edit_hub.title + : t.client_hubs.add_hub_dialog.title; + + final String buttonText = isEditing + ? t.client_hubs.edit_hub.save_button + : t.client_hubs.add_hub_dialog.create_button; + + return Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.15), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.all(UiConstants.space6), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: UiTypography.headline3m.textPrimary.copyWith( + fontSize: 20, + ), + ), + const SizedBox(height: UiConstants.space5), + + // โ”€โ”€ Hub Name โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label), + const SizedBox(height: UiConstants.space2), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return t.client_hubs.add_hub_dialog.name_required; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + + const SizedBox(height: UiConstants.space4), + + // โ”€โ”€ Cost Center โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label), + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: _showCostCenterSelector, + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 16, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFD), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + border: Border.all( + color: _selectedCostCenterId != null + ? UiColors.primary + : UiColors.primary.withValues(alpha: 0.1), + width: _selectedCostCenterId != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _selectedCostCenterId != null + ? _getCostCenterName(_selectedCostCenterId!) + : t.client_hubs.add_hub_dialog.cost_center_hint, + style: _selectedCostCenterId != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder.copyWith( + color: UiColors.textSecondary.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + + const SizedBox(height: UiConstants.space4), + + // โ”€โ”€ Address โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label), + const SizedBox(height: UiConstants.space2), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.address_hint, + ), + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + + const SizedBox(height: UiConstants.space8), + + // โ”€โ”€ Buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Row( + children: [ + Expanded( + child: UiButton.secondary( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: UiColors.primary.withValues(alpha: 0.1), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase * 1.5, + ), + ), + ), + onPressed: widget.onCancel, + text: t.common.cancel, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase * 1.5, + ), + ), + ), + onPressed: () { + if (_formKey.currentState!.validate()) { + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show( + context, + message: t.client_hubs.add_hub_dialog.address_required, + type: UiSnackbarType.error, + ); + return; + } + + widget.onSave( + name: _nameController.text.trim(), + address: _addressController.text.trim(), + costCenterId: _selectedCostCenterId, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: buttonText, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + InputDecoration _buildInputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder.copyWith( + color: UiColors.textSecondary.withValues(alpha: 0.5), + ), + filled: true, + fillColor: const Color(0xFFF8FAFD), + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: const BorderSide(color: UiColors.primary, width: 2), + ), + errorStyle: UiTypography.footnote2r.textError, + ); + } + + String _getCostCenterName(String id) { + try { + return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name; + } catch (_) { + return id; + } + } + + Future _showCostCenterSelector() async { + final CostCenter? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_hubs.add_hub_dialog.cost_center_label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: widget.costCenters.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.add_hub_dialog.cost_centers_empty), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: widget.costCenters.length, + itemBuilder: (BuildContext context, int index) { + final CostCenter cc = widget.costCenters[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(cc.name, style: UiTypography.body1m.textPrimary), + subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null, + onTap: () => Navigator.of(context).pop(cc), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + setState(() { + _selectedCostCenterId = selected.id; + }); + } + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart index 013e533c..634d9029 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart @@ -31,10 +31,7 @@ class HubInfoCard extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( t.client_hubs.about_hubs.description, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textSecondary, - height: 1.4, - ), + style: UiTypography.footnote1r.textSecondary, ), ], ), diff --git a/apps/mobile/packages/features/client/orders/analyze.txt b/apps/mobile/packages/features/client/orders/analyze.txt new file mode 100644 index 00000000..28d6d1d5 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze.txt differ diff --git a/apps/mobile/packages/features/client/orders/analyze_output.txt b/apps/mobile/packages/features/client/orders/analyze_output.txt new file mode 100644 index 00000000..53f8069c Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze_output.txt differ diff --git a/apps/mobile/packages/features/client/create_order/lib/client_create_order.dart b/apps/mobile/packages/features/client/orders/create_order/lib/client_create_order.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/client_create_order.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/client_create_order.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart similarity index 63% rename from apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index 0e2624e2..e459dd35 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -5,11 +5,11 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/usecases/create_one_time_order_usecase.dart'; +import 'domain/usecases/create_permanent_order_usecase.dart'; +import 'domain/usecases/create_recurring_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; -import 'domain/usecases/get_order_types_usecase.dart'; -import 'presentation/blocs/client_create_order_bloc.dart'; -import 'presentation/blocs/one_time_order_bloc.dart'; -import 'presentation/blocs/rapid_order_bloc.dart'; +import 'domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'presentation/blocs/index.dart'; import 'presentation/pages/create_order_page.dart'; import 'presentation/pages/one_time_order_page.dart'; import 'presentation/pages/permanent_order_page.dart'; @@ -28,17 +28,22 @@ class ClientCreateOrderModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton(ClientCreateOrderRepositoryImpl.new); + i.addLazySingleton( + ClientCreateOrderRepositoryImpl.new, + ); // UseCases - i.addLazySingleton(GetOrderTypesUseCase.new); i.addLazySingleton(CreateOneTimeOrderUseCase.new); + i.addLazySingleton(CreatePermanentOrderUseCase.new); + i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRapidOrderUseCase.new); + i.addLazySingleton(GetOrderDetailsForReorderUseCase.new); // BLoCs - i.add(ClientCreateOrderBloc.new); i.add(RapidOrderBloc.new); i.add(OneTimeOrderBloc.new); + i.add(PermanentOrderBloc.new); + i.add(RecurringOrderBloc.new); } @override @@ -48,19 +53,31 @@ class ClientCreateOrderModule extends Module { child: (BuildContext context) => const ClientCreateOrderPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrderRapid), + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderRapid, + ), child: (BuildContext context) => const RapidOrderPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrderOneTime), + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderOneTime, + ), child: (BuildContext context) => const OneTimeOrderPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrderRecurring), + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderRecurring, + ), child: (BuildContext context) => const RecurringOrderPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrderPermanent), + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderPermanent, + ), child: (BuildContext context) => const PermanentOrderPage(), ); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart new file mode 100644 index 00000000..aea8a443 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -0,0 +1,596 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/repositories/client_create_order_repository_interface.dart'; + +/// Implementation of [ClientCreateOrderRepositoryInterface]. +/// +/// This implementation coordinates data access for order creation by [DataConnectService] from the shared +/// Data Connect package. +/// +/// It follows the KROW Clean Architecture by keeping the data layer focused +/// on delegation and data mapping, without business logic. +class ClientCreateOrderRepositoryImpl + implements ClientCreateOrderRepositoryInterface { + ClientCreateOrderRepositoryImpl({required dc.DataConnectService service}) + : _service = service; + + final dc.DataConnectService _service; + + @override + Future createOneTimeOrder(domain.OneTimeOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.OneTimeOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.date.year, + order.date.month, + order.date.day, + ); + final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final OperationResult + orderResult = await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.ONE_TIME, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.OneTimeOrderPosition position) => sum + position.count, + ); + final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}'; + final double shiftCost = _calculateShiftCost(order); + + final OperationResult + shiftResult = await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(orderTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.OPEN) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + + for (final domain.OneTimeOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.date, position.startTime); + final DateTime end = _parseTime(order.date, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(AnyValue([shiftId])) + .execute(); + }); + } + + @override + Future createRecurringOrder(domain.RecurringOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.RecurringOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.startDate.year, + order.startDate.month, + order.startDate.day, + ); + final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final Timestamp startTimestamp = _service.toTimestamp(order.startDate); + final Timestamp endTimestamp = _service.toTimestamp(order.endDate); + + final OperationResult + orderResult = await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.RECURRING, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .endDate(endTimestamp) + .recurringDays(order.recurringDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + // NOTE: Recurring orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + final DateTime effectiveEndDate = order.endDate.isAfter(maxEndDate) + ? maxEndDate + : order.endDate; + + final Set selectedDays = Set.from(order.recurringDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.RecurringOrderPosition position) => + sum + position.count, + ); + final double shiftCost = _calculateRecurringShiftCost(order); + + final List shiftIds = []; + for ( + DateTime day = orderDateOnly; + !day.isAfter(effectiveEndDate); + day = day.add(const Duration(days: 1)) + ) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final OperationResult + shiftResult = await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.OPEN) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(AnyValue(shiftIds)) + .execute(); + }); + } + + @override + Future createPermanentOrder(domain.PermanentOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.OneTimeOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.startDate.year, + order.startDate.month, + order.startDate.day, + ); + final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final Timestamp startTimestamp = _service.toTimestamp(order.startDate); + + final OperationResult + orderResult = await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.PERMANENT, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .permanentDays(order.permanentDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + // NOTE: Permanent orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + + final Set selectedDays = Set.from(order.permanentDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.OneTimeOrderPosition position) => sum + position.count, + ); + final double shiftCost = _calculatePermanentShiftCost(order); + + final List shiftIds = []; + for ( + DateTime day = orderDateOnly; + !day.isAfter(maxEndDate); + day = day.add(const Duration(days: 1)) + ) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final OperationResult + shiftResult = await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.OPEN) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.OneTimeOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(AnyValue(shiftIds)) + .execute(); + }); + } + + @override + Future createRapidOrder(String description) async { + // TO-DO: connect IA and return array with the information. + throw UnimplementedError('Rapid order IA is not connected yet.'); + } + + @override + Future reorder(String previousOrderId, DateTime newDate) async { + // TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date. + throw UnimplementedError('Reorder functionality is not yet implemented.'); + } + + @override + Future getOrderDetailsForReorder(String orderId) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final QueryResult< + dc.ListShiftRolesByBusinessAndOrderData, + dc.ListShiftRolesByBusinessAndOrderVariables + > + result = await _service.connector + .listShiftRolesByBusinessAndOrder( + businessId: businessId, + orderId: orderId, + ) + .execute(); + + final List shiftRoles = + result.data.shiftRoles; + + if (shiftRoles.isEmpty) { + throw Exception('Order not found or has no roles.'); + } + + final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrder order = + shiftRoles.first.shift.order; + + final domain.OrderType orderType = _mapOrderType(order.orderType); + + final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub + teamHub = order.teamHub; + + return domain.ReorderData( + orderId: orderId, + eventName: order.eventName ?? '', + vendorId: order.vendorId ?? '', + orderType: orderType, + hub: domain.OneTimeOrderHubDetails( + id: teamHub.id, + name: teamHub.hubName, + address: teamHub.address, + placeId: teamHub.placeId, + latitude: 0, // Not available in this query + longitude: 0, + ), + positions: shiftRoles.map(( + dc.ListShiftRolesByBusinessAndOrderShiftRoles role, + ) { + return domain.ReorderPosition( + roleId: role.roleId, + count: role.count, + startTime: _formatTimestamp(role.startTime), + endTime: _formatTimestamp(role.endTime), + lunchBreak: _formatBreakDuration(role.breakType), + ); + }).toList(), + startDate: order.startDate?.toDateTime(), + endDate: order.endDate?.toDateTime(), + recurringDays: order.recurringDays ?? const [], + permanentDays: order.permanentDays ?? const [], + ); + }); + } + + double _calculateShiftCost(domain.OneTimeOrder order) { + double total = 0; + for (final domain.OneTimeOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.date, position.startTime); + final DateTime end = _parseTime(order.date, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + + double _calculateRecurringShiftCost(domain.RecurringOrder order) { + double total = 0; + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + + double _calculatePermanentShiftCost(domain.PermanentOrder order) { + double total = 0; + for (final domain.OneTimeOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + + String _weekdayLabel(DateTime date) { + switch (date.weekday) { + case DateTime.monday: + return 'MON'; + case DateTime.tuesday: + return 'TUE'; + case DateTime.wednesday: + return 'WED'; + case DateTime.thursday: + return 'THU'; + case DateTime.friday: + return 'FRI'; + case DateTime.saturday: + return 'SAT'; + case DateTime.sunday: + default: + return 'SUN'; + } + } + + dc.BreakDuration _breakDurationFromValue(String value) { + switch (value) { + case 'MIN_10': + return dc.BreakDuration.MIN_10; + case 'MIN_15': + return dc.BreakDuration.MIN_15; + case 'MIN_30': + return dc.BreakDuration.MIN_30; + case 'MIN_45': + return dc.BreakDuration.MIN_45; + case 'MIN_60': + return dc.BreakDuration.MIN_60; + default: + return dc.BreakDuration.NO_BREAK; + } + } + + bool _isBreakPaid(String value) { + return value == 'MIN_10' || value == 'MIN_15'; + } + + DateTime _parseTime(DateTime date, String time) { + if (time.trim().isEmpty) { + throw Exception('Shift time is missing.'); + } + + DateTime parsed; + try { + parsed = DateFormat.jm().parse(time); + } catch (_) { + parsed = DateFormat.Hm().parse(time); + } + + return DateTime( + date.year, + date.month, + date.day, + parsed.hour, + parsed.minute, + ); + } + + String _formatDate(DateTime dateTime) { + final String year = dateTime.year.toString().padLeft(4, '0'); + final String month = dateTime.month.toString().padLeft(2, '0'); + final String day = dateTime.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; + } + + String _formatTimestamp(Timestamp? value) { + if (value == null) return ''; + try { + return DateFormat('HH:mm').format(value.toDateTime()); + } catch (_) { + return ''; + } + } + + String _formatBreakDuration(dc.EnumValue? breakType) { + if (breakType is dc.Known) { + switch (breakType.value) { + case dc.BreakDuration.MIN_10: + return 'MIN_10'; + case dc.BreakDuration.MIN_15: + return 'MIN_15'; + case dc.BreakDuration.MIN_30: + return 'MIN_30'; + case dc.BreakDuration.MIN_45: + return 'MIN_45'; + case dc.BreakDuration.MIN_60: + return 'MIN_60'; + case dc.BreakDuration.NO_BREAK: + return 'NO_BREAK'; + } + } + return 'NO_BREAK'; + } + + domain.OrderType _mapOrderType(dc.EnumValue? orderType) { + if (orderType is dc.Known) { + switch (orderType.value) { + case dc.OrderType.ONE_TIME: + return domain.OrderType.oneTime; + case dc.OrderType.RECURRING: + return domain.OrderType.recurring; + case dc.OrderType.PERMANENT: + return domain.OrderType.permanent; + case dc.OrderType.RAPID: + return domain.OrderType.oneTime; + } + } + return domain.OrderType.oneTime; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/one_time_order_arguments.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart new file mode 100644 index 00000000..0c0d5736 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class PermanentOrderArguments { + const PermanentOrderArguments({required this.order}); + final PermanentOrder order; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/rapid_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/rapid_order_arguments.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/rapid_order_arguments.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/rapid_order_arguments.dart diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart new file mode 100644 index 00000000..8c0c3d99 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class RecurringOrderArguments { + const RecurringOrderArguments({required this.order}); + final RecurringOrder order; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart new file mode 100644 index 00000000..a2c80cd5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart @@ -0,0 +1,37 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Interface for the Client Create Order repository. +/// +/// This repository is responsible for: +/// 1. Submitting different types of staffing orders (Rapid, One-Time, Recurring, Permanent). +/// +/// It follows the KROW Clean Architecture by defining the contract in the +/// domain layer, to be implemented in the data layer. +abstract interface class ClientCreateOrderRepositoryInterface { + /// Submits a one-time staffing order with specific details. + /// + /// [order] contains the date, location, and required positions. + Future createOneTimeOrder(OneTimeOrder order); + + /// Submits a recurring staffing order with specific details. + Future createRecurringOrder(RecurringOrder order); + + /// Submits a permanent staffing order with specific details. + Future createPermanentOrder(PermanentOrder order); + + /// Submits a rapid (urgent) staffing order via a text description. + /// + /// [description] is the text message (or transcribed voice) describing the need. + Future createRapidOrder(String description); + + /// Reorders an existing staffing order with a new date. + /// + /// [previousOrderId] is the ID of the order to reorder. + /// [newDate] is the new date for the order. + Future reorder(String previousOrderId, DateTime newDate); + + /// Fetches the details of an existing order to be used as a template for a new order. + /// + /// returns [ReorderData] containing the order details and positions. + Future getOrderDetailsForReorder(String orderId); +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart new file mode 100644 index 00000000..b79b3359 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a permanent staffing order. +class CreatePermanentOrderUseCase implements UseCase { + const CreatePermanentOrderUseCase(this._repository); + + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(PermanentOrder params) { + return _repository.createPermanentOrder(params); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_rapid_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_rapid_order_usecase.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_rapid_order_usecase.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_rapid_order_usecase.dart diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart new file mode 100644 index 00000000..561a5ef8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a recurring staffing order. +class CreateRecurringOrderUseCase implements UseCase { + const CreateRecurringOrderUseCase(this._repository); + + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(RecurringOrder params) { + return _repository.createRecurringOrder(params); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart new file mode 100644 index 00000000..9490ccb5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for fetching order details for reordering. +class GetOrderDetailsForReorderUseCase implements UseCase { + const GetOrderDetailsForReorderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(String orderId) { + return _repository.getOrderDetailsForReorder(orderId); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart new file mode 100644 index 00000000..ddd90f2c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Arguments for the ReorderUseCase. +class ReorderArguments { + const ReorderArguments({ + required this.previousOrderId, + required this.newDate, + }); + + final String previousOrderId; + final DateTime newDate; +} + +/// Use case for reordering an existing staffing order. +class ReorderUseCase implements UseCase { + const ReorderUseCase(this._repository); + + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(ReorderArguments params) { + return _repository.reorder(params.previousOrderId, params.newDate); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/index.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/index.dart new file mode 100644 index 00000000..36ed5304 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/index.dart @@ -0,0 +1,4 @@ +export 'one_time_order/index.dart'; +export 'rapid_order/index.dart'; +export 'recurring_order/index.dart'; +export 'permanent_order/index.dart'; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/index.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/index.dart new file mode 100644 index 00000000..c096a4c2 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/index.dart @@ -0,0 +1,3 @@ +export 'one_time_order_bloc.dart'; +export 'one_time_order_event.dart'; +export 'one_time_order_state.dart'; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart similarity index 54% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 7e11f0eb..a255fe7d 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -1,18 +1,25 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:client_create_order/src/domain/arguments/one_time_order_arguments.dart'; +import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/one_time_order_arguments.dart'; -import '../../domain/usecases/create_one_time_order_usecase.dart'; + import 'one_time_order_event.dart'; import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. class OneTimeOrderBloc extends Bloc - with BlocErrorHandler, SafeBloc { - OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._service) - : super(OneTimeOrderState.initial()) { + with + BlocErrorHandler, + SafeBloc { + OneTimeOrderBloc( + this._createOneTimeOrderUseCase, + this._getOrderDetailsForReorderUseCase, + this._service, + ) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); on(_onHubsLoaded); @@ -23,18 +30,24 @@ class OneTimeOrderBloc extends Bloc on(_onPositionRemoved); on(_onPositionUpdated); on(_onSubmitted); + on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); } final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; + final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; final dc.DataConnectService _service; Future _loadVendors() async { final List? vendors = await handleErrorWithResult( action: () async { - final QueryResult result = - await _service.connector.listVendors().execute(); + final fdc.QueryResult result = await _service + .connector + .listVendors() + .execute(); return result.data.vendors .map( (dc.ListVendorsVendors vendor) => Vendor( @@ -53,11 +66,19 @@ class OneTimeOrderBloc extends Bloc } } - Future _loadRolesForVendor(String vendorId, Emitter emit) async { + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { final List? roles = await handleErrorWithResult( action: () async { - final QueryResult - result = await _service.connector.listRolesByVendorId(vendorId: vendorId).execute(); + final fdc.QueryResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); return result.data.roles .map( (dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption( @@ -68,7 +89,8 @@ class OneTimeOrderBloc extends Bloc ) .toList(); }, - onError: (_) => emit(state.copyWith(roles: const [])), + onError: (_) => + emit(state.copyWith(roles: const [])), ); if (roles != null) { @@ -80,7 +102,10 @@ class OneTimeOrderBloc extends Bloc final List? hubs = await handleErrorWithResult( action: () async { final String businessId = await _service.getBusinessId(); - final QueryResult + final fdc.QueryResult< + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > result = await _service.connector .listTeamHubsByOwnerId(ownerId: businessId) .execute(); @@ -102,7 +127,8 @@ class OneTimeOrderBloc extends Bloc ) .toList(); }, - onError: (_) => add(const OneTimeOrderHubsLoaded([])), + onError: (_) => + add(const OneTimeOrderHubsLoaded([])), ); if (hubs != null) { @@ -110,17 +136,52 @@ class OneTimeOrderBloc extends Bloc } } + Future _loadManagersForHub( + String hubId, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + OneTimeOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) { + add(const OneTimeOrderManagersLoaded([])); + }, + ); + + if (managers != null) { + add(OneTimeOrderManagersLoaded(managers)); + } + } + + Future _onVendorsLoaded( OneTimeOrderVendorsLoaded event, Emitter emit, ) async { - final Vendor? selectedVendor = - event.vendors.isNotEmpty ? event.vendors.first : null; + final Vendor? selectedVendor = event.vendors.isNotEmpty + ? event.vendors.first + : null; emit( - state.copyWith( - vendors: event.vendors, - selectedVendor: selectedVendor, - ), + state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); @@ -139,8 +200,9 @@ class OneTimeOrderBloc extends Bloc OneTimeOrderHubsLoaded event, Emitter emit, ) { - final OneTimeOrderHubOption? selectedHub = - event.hubs.isNotEmpty ? event.hubs.first : null; + final OneTimeOrderHubOption? selectedHub = event.hubs.isNotEmpty + ? event.hubs.first + : null; emit( state.copyWith( hubs: event.hubs, @@ -148,20 +210,36 @@ class OneTimeOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id); + } } + void _onHubChanged( OneTimeOrderHubChanged event, Emitter emit, ) { - emit( - state.copyWith( - selectedHub: event.hub, - location: event.hub.name, - ), - ); + emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id); } + void _onHubManagerChanged( + OneTimeOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + OneTimeOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + void _onEventNameChanged( OneTimeOrderEventNameChanged event, Emitter emit, @@ -220,7 +298,7 @@ class OneTimeOrderBloc extends Bloc ) async { emit(state.copyWith(status: OneTimeOrderStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final Map roleRates = { for (final OneTimeOrderRoleOption role in state.roles) @@ -249,6 +327,7 @@ class OneTimeOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); @@ -260,4 +339,74 @@ class OneTimeOrderBloc extends Bloc ), ); } + + Future _onInitialized( + OneTimeOrderInitialized event, + Emitter emit, + ) async { + final Map data = event.data; + final String title = data['title']?.toString() ?? ''; + final DateTime? startDate = data['startDate'] as DateTime?; + final String? orderId = data['orderId']?.toString(); + + emit(state.copyWith(eventName: title, date: startDate ?? DateTime.now())); + + if (orderId == null || orderId.isEmpty) return; + + emit(state.copyWith(status: OneTimeOrderStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + final ReorderData orderDetails = + await _getOrderDetailsForReorderUseCase(orderId); + + // Map positions + final List positions = orderDetails.positions.map( + (ReorderPosition role) { + return OneTimeOrderPosition( + role: role.roleId, + count: role.count, + startTime: role.startTime, + endTime: role.endTime, + lunchBreak: role.lunchBreak, + ); + }, + ).toList(); + + // Update state with order details + final Vendor? selectedVendor = state.vendors + .where((Vendor v) => v.id == orderDetails.vendorId) + .firstOrNull; + + final OneTimeOrderHubOption? selectedHub = state.hubs + .where( + (OneTimeOrderHubOption h) => + h.placeId == orderDetails.hub.placeId, + ) + .firstOrNull; + + emit( + state.copyWith( + eventName: orderDetails.eventName.isNotEmpty + ? orderDetails.eventName + : title, + positions: positions, + selectedVendor: selectedVendor, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + status: OneTimeOrderStatus.initial, + ), + ); + + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + }, + onError: (String errorKey) => state.copyWith( + status: OneTimeOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart similarity index 76% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart index 7258c2d0..b64f0542 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart @@ -81,3 +81,29 @@ class OneTimeOrderPositionUpdated extends OneTimeOrderEvent { class OneTimeOrderSubmitted extends OneTimeOrderEvent { const OneTimeOrderSubmitted(); } + +class OneTimeOrderInitialized extends OneTimeOrderEvent { + const OneTimeOrderInitialized(this.data); + final Map data; + + @override + List get props => [data]; +} + +class OneTimeOrderHubManagerChanged extends OneTimeOrderEvent { + const OneTimeOrderHubManagerChanged(this.manager); + final OneTimeOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class OneTimeOrderManagersLoaded extends OneTimeOrderEvent { + const OneTimeOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + + diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart similarity index 84% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index d21bbfc3..b48b9134 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -16,6 +16,8 @@ class OneTimeOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory OneTimeOrderState.initial() { @@ -29,6 +31,7 @@ class OneTimeOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } final DateTime date; @@ -42,6 +45,8 @@ class OneTimeOrderState extends Equatable { final List hubs; final OneTimeOrderHubOption? selectedHub; final List roles; + final List managers; + final OneTimeOrderManagerOption? selectedManager; OneTimeOrderState copyWith({ DateTime? date, @@ -55,6 +60,8 @@ class OneTimeOrderState extends Equatable { List? hubs, OneTimeOrderHubOption? selectedHub, List? roles, + List? managers, + OneTimeOrderManagerOption? selectedManager, }) { return OneTimeOrderState( date: date ?? this.date, @@ -68,6 +75,8 @@ class OneTimeOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -98,6 +107,8 @@ class OneTimeOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -158,3 +169,17 @@ class OneTimeOrderRoleOption extends Equatable { @override List get props => [id, name, costPerHour]; } + +class OneTimeOrderManagerOption extends Equatable { + const OneTimeOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/index.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/index.dart new file mode 100644 index 00000000..afc5e109 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/index.dart @@ -0,0 +1,3 @@ +export 'permanent_order_bloc.dart'; +export 'permanent_order_event.dart'; +export 'permanent_order_state.dart'; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart new file mode 100644 index 00000000..5c0c34af --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -0,0 +1,486 @@ +import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; + +import 'permanent_order_event.dart'; +import 'permanent_order_state.dart'; + +/// BLoC for managing the permanent order creation form. +class PermanentOrderBloc extends Bloc + with + BlocErrorHandler, + SafeBloc { + PermanentOrderBloc( + this._createPermanentOrderUseCase, + this._getOrderDetailsForReorderUseCase, + this._service, + ) : super(PermanentOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); + + _loadVendors(); + _loadHubs(); + } + + final CreatePermanentOrderUseCase _createPermanentOrderUseCase; + final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + Future _loadVendors() async { + final List? vendors = await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = await _service + .connector + .listVendors() + .execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => domain.Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }, + onError: (_) => add(const PermanentOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(PermanentOrderVendorsLoaded(vendors)); + } + } + + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { + final List? roles = await handleErrorWithResult( + action: () async { + final fdc.QueryResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }, + onError: (_) => + emit(state.copyWith(roles: const [])), + ); + + if (roles != null) { + emit(state.copyWith(roles: roles)); + } + } + + Future _loadHubs() async { + final List? hubs = await handleErrorWithResult( + action: () async { + final String businessId = await _service.getBusinessId(); + final fdc.QueryResult< + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > + result = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption( + id: hub.id, + name: hub.hubName, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ), + ) + .toList(); + }, + onError: (_) => + add(const PermanentOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(PermanentOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + PermanentOrderVendorsLoaded event, + Emitter emit, + ) async { + final domain.Vendor? selectedVendor = event.vendors.isNotEmpty + ? event.vendors.first + : null; + emit( + state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + ); + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + } + + Future _onVendorChanged( + PermanentOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + void _onHubsLoaded( + PermanentOrderHubsLoaded event, + Emitter emit, + ) { + final PermanentOrderHubOption? selectedHub = event.hubs.isNotEmpty + ? event.hubs.first + : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id, emit); + } + } + + void _onHubChanged( + PermanentOrderHubChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id, emit); + } + + void _onHubManagerChanged( + PermanentOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + PermanentOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + PermanentOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } + } + + + void _onEventNameChanged( + PermanentOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + PermanentOrderStartDateChanged event, + Emitter emit, + ) { + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.permanentDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + permanentDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + void _onDayToggled( + PermanentOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.permanentDays); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit( + state.copyWith( + permanentDays: _sortDays(days), + autoSelectedDayIndex: autoIndex, + ), + ); + } + + void _onPositionAdded( + PermanentOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const PermanentOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + PermanentOrderPositionRemoved event, + Emitter emit, + ) { + if (state.positions.length > 1) { + final List newPositions = + List.from(state.positions) + ..removeAt(event.index); + emit(state.copyWith(positions: newPositions)); + } + } + + void _onPositionUpdated( + PermanentOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + PermanentOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PermanentOrderStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + final Map roleRates = { + for (final PermanentOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final PermanentOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw const domain.OrderMissingHubException(); + } + final domain.PermanentOrder order = domain.PermanentOrder( + startDate: state.startDate, + permanentDays: state.permanentDays, + positions: state.positions + .map( + (PermanentOrderPosition p) => domain.OneTimeOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.OneTimeOrderHubDetails( + id: selectedHub.id, + name: selectedHub.name, + address: selectedHub.address, + placeId: selectedHub.placeId, + latitude: selectedHub.latitude, + longitude: selectedHub.longitude, + city: selectedHub.city, + state: selectedHub.state, + street: selectedHub.street, + country: selectedHub.country, + zipCode: selectedHub.zipCode, + ), + eventName: state.eventName, + vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, + roleRates: roleRates, + ); + await _createPermanentOrderUseCase(order); + emit(state.copyWith(status: PermanentOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: PermanentOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onInitialized( + PermanentOrderInitialized event, + Emitter emit, + ) async { + final Map data = event.data; + final String title = data['title']?.toString() ?? ''; + final DateTime? startDate = data['startDate'] as DateTime?; + final String? orderId = data['orderId']?.toString(); + + emit( + state.copyWith(eventName: title, startDate: startDate ?? DateTime.now()), + ); + + if (orderId == null || orderId.isEmpty) return; + + emit(state.copyWith(status: PermanentOrderStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + final domain.ReorderData orderDetails = + await _getOrderDetailsForReorderUseCase(orderId); + + // Map positions + final List positions = orderDetails.positions + .map((domain.ReorderPosition role) { + return PermanentOrderPosition( + role: role.roleId, + count: role.count, + startTime: role.startTime, + endTime: role.endTime, + lunchBreak: role.lunchBreak, + ); + }) + .toList(); + + // Update state with order details + final domain.Vendor? selectedVendor = state.vendors + .where((domain.Vendor v) => v.id == orderDetails.vendorId) + .firstOrNull; + + final PermanentOrderHubOption? selectedHub = state.hubs + .where( + (PermanentOrderHubOption h) => + h.placeId == orderDetails.hub.placeId, + ) + .firstOrNull; + + emit( + state.copyWith( + eventName: orderDetails.eventName.isNotEmpty + ? orderDetails.eventName + : title, + positions: positions, + selectedVendor: selectedVendor, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + status: PermanentOrderStatus.initial, + startDate: startDate ?? orderDetails.startDate ?? DateTime.now(), + permanentDays: orderDetails.permanentDays, + ), + ); + + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + }, + onError: (String errorKey) => state.copyWith( + status: PermanentOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart new file mode 100644 index 00000000..f194618c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart @@ -0,0 +1,125 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'permanent_order_state.dart'; + +abstract class PermanentOrderEvent extends Equatable { + const PermanentOrderEvent(); + + @override + List get props => []; +} + +class PermanentOrderVendorsLoaded extends PermanentOrderEvent { + const PermanentOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class PermanentOrderVendorChanged extends PermanentOrderEvent { + const PermanentOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class PermanentOrderHubsLoaded extends PermanentOrderEvent { + const PermanentOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class PermanentOrderHubChanged extends PermanentOrderEvent { + const PermanentOrderHubChanged(this.hub); + + final PermanentOrderHubOption hub; + + @override + List get props => [hub]; +} + +class PermanentOrderEventNameChanged extends PermanentOrderEvent { + const PermanentOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class PermanentOrderStartDateChanged extends PermanentOrderEvent { + const PermanentOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class PermanentOrderDayToggled extends PermanentOrderEvent { + const PermanentOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class PermanentOrderPositionAdded extends PermanentOrderEvent { + const PermanentOrderPositionAdded(); +} + +class PermanentOrderPositionRemoved extends PermanentOrderEvent { + const PermanentOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class PermanentOrderPositionUpdated extends PermanentOrderEvent { + const PermanentOrderPositionUpdated(this.index, this.position); + + final int index; + final PermanentOrderPosition position; + + @override + List get props => [index, position]; +} + +class PermanentOrderSubmitted extends PermanentOrderEvent { + const PermanentOrderSubmitted(); +} + +class PermanentOrderInitialized extends PermanentOrderEvent { + const PermanentOrderInitialized(this.data); + final Map data; + + @override + List get props => [data]; +} + +class PermanentOrderHubManagerChanged extends PermanentOrderEvent { + const PermanentOrderHubManagerChanged(this.manager); + final PermanentOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class PermanentOrderManagersLoaded extends PermanentOrderEvent { + const PermanentOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart new file mode 100644 index 00000000..4cd04e66 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -0,0 +1,246 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum PermanentOrderStatus { initial, loading, success, failure } + +class PermanentOrderState extends Equatable { + const PermanentOrderState({ + required this.startDate, + required this.permanentDays, + required this.location, + required this.eventName, + required this.positions, + required this.autoSelectedDayIndex, + this.status = PermanentOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + this.managers = const [], + this.selectedManager, + }); + + factory PermanentOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return PermanentOrderState( + startDate: start, + permanentDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + PermanentOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + vendors: const [], + hubs: const [], + roles: const [], + managers: const [], + ); + } + + final DateTime startDate; + final List permanentDays; + final String location; + final String eventName; + final List positions; + final int? autoSelectedDayIndex; + final PermanentOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final PermanentOrderHubOption? selectedHub; + final List roles; + final List managers; + final PermanentOrderManagerOption? selectedManager; + + PermanentOrderState copyWith({ + DateTime? startDate, + List? permanentDays, + String? location, + String? eventName, + List? positions, + int? autoSelectedDayIndex, + PermanentOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + PermanentOrderHubOption? selectedHub, + List? roles, + List? managers, + PermanentOrderManagerOption? selectedManager, + }) { + return PermanentOrderState( + startDate: startDate ?? this.startDate, + permanentDays: permanentDays ?? this.permanentDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, + hubs: hubs ?? this.hubs, + selectedHub: selectedHub ?? this.selectedHub, + roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, + ); + } + + bool get isValid { + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + permanentDays.isNotEmpty && + positions.every( + (PermanentOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + permanentDays, + location, + eventName, + positions, + autoSelectedDayIndex, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + managers, + selectedManager, + ]; +} + +class PermanentOrderHubOption extends Equatable { + const PermanentOrderHubOption({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class PermanentOrderRoleOption extends Equatable { + const PermanentOrderRoleOption({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class PermanentOrderManagerOption extends Equatable { + const PermanentOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + +class PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak, + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String? lunchBreak; + + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return PermanentOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/index.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/index.dart new file mode 100644 index 00000000..34b84929 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/index.dart @@ -0,0 +1,3 @@ +export 'rapid_order_bloc.dart'; +export 'rapid_order_event.dart'; +export 'rapid_order_state.dart'; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart similarity index 93% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart index cfb3860b..b9fdedf5 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart @@ -1,7 +1,8 @@ +import 'package:client_create_order/src/domain/arguments/rapid_order_arguments.dart'; +import 'package:client_create_order/src/domain/usecases/create_rapid_order_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import '../../domain/arguments/rapid_order_arguments.dart'; -import '../../domain/usecases/create_rapid_order_usecase.dart'; + import 'rapid_order_event.dart'; import 'rapid_order_state.dart'; @@ -69,7 +70,7 @@ class RapidOrderBloc extends Bloc emit(const RapidOrderSubmitting()); await handleError( - emit: emit, + emit: emit.call, action: () async { await _createRapidOrderUseCase( RapidOrderArguments(description: message), diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_event.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_event.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_event.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_state.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/index.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/index.dart new file mode 100644 index 00000000..cfcc77f5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/index.dart @@ -0,0 +1,3 @@ +export 'recurring_order_bloc.dart'; +export 'recurring_order_event.dart'; +export 'recurring_order_state.dart'; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart new file mode 100644 index 00000000..2c51fef9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -0,0 +1,505 @@ +import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; + +import 'recurring_order_event.dart'; +import 'recurring_order_state.dart'; + +/// BLoC for managing the recurring order creation form. +class RecurringOrderBloc extends Bloc + with + BlocErrorHandler, + SafeBloc { + RecurringOrderBloc( + this._createRecurringOrderUseCase, + this._getOrderDetailsForReorderUseCase, + this._service, + ) : super(RecurringOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onEndDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); + + _loadVendors(); + _loadHubs(); + } + + final CreateRecurringOrderUseCase _createRecurringOrderUseCase; + final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + Future _loadVendors() async { + final List? vendors = await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = await _service + .connector + .listVendors() + .execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => domain.Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }, + onError: (_) => add(const RecurringOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(RecurringOrderVendorsLoaded(vendors)); + } + } + + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { + final List? roles = await handleErrorWithResult( + action: () async { + final fdc.QueryResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }, + onError: (_) => + emit(state.copyWith(roles: const [])), + ); + + if (roles != null) { + emit(state.copyWith(roles: roles)); + } + } + + Future _loadHubs() async { + final List? hubs = await handleErrorWithResult( + action: () async { + final String businessId = await _service.getBusinessId(); + final fdc.QueryResult< + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > + result = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption( + id: hub.id, + name: hub.hubName, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ), + ) + .toList(); + }, + onError: (_) => + add(const RecurringOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(RecurringOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + RecurringOrderVendorsLoaded event, + Emitter emit, + ) async { + final domain.Vendor? selectedVendor = event.vendors.isNotEmpty + ? event.vendors.first + : null; + emit( + state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + ); + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + } + + Future _onVendorChanged( + RecurringOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + Future _onHubsLoaded( + RecurringOrderHubsLoaded event, + Emitter emit, + ) async { + final RecurringOrderHubOption? selectedHub = event.hubs.isNotEmpty + ? event.hubs.first + : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + + if (selectedHub != null) { + await _loadManagersForHub(selectedHub.id, emit); + } + } + + Future _onHubChanged( + RecurringOrderHubChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + await _loadManagersForHub(event.hub.id, emit); + } + + void _onHubManagerChanged( + RecurringOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + RecurringOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + RecurringOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } + } + + void _onEventNameChanged( + RecurringOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + RecurringOrderStartDateChanged event, + Emitter emit, + ) { + DateTime endDate = state.endDate; + if (endDate.isBefore(event.date)) { + endDate = event.date; + } + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.recurringDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + endDate: endDate, + recurringDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + void _onEndDateChanged( + RecurringOrderEndDateChanged event, + Emitter emit, + ) { + DateTime startDate = state.startDate; + if (event.date.isBefore(startDate)) { + startDate = event.date; + } + emit(state.copyWith(endDate: event.date, startDate: startDate)); + } + + void _onDayToggled( + RecurringOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.recurringDays); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit( + state.copyWith( + recurringDays: _sortDays(days), + autoSelectedDayIndex: autoIndex, + ), + ); + } + + void _onPositionAdded( + RecurringOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const RecurringOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + RecurringOrderPositionRemoved event, + Emitter emit, + ) { + if (state.positions.length > 1) { + final List newPositions = + List.from(state.positions) + ..removeAt(event.index); + emit(state.copyWith(positions: newPositions)); + } + } + + void _onPositionUpdated( + RecurringOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + RecurringOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: RecurringOrderStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + final Map roleRates = { + for (final RecurringOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final RecurringOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw const domain.OrderMissingHubException(); + } + final domain.RecurringOrder order = domain.RecurringOrder( + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.recurringDays, + location: selectedHub.name, + positions: state.positions + .map( + (RecurringOrderPosition p) => domain.RecurringOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.RecurringOrderHubDetails( + id: selectedHub.id, + name: selectedHub.name, + address: selectedHub.address, + placeId: selectedHub.placeId, + latitude: selectedHub.latitude, + longitude: selectedHub.longitude, + city: selectedHub.city, + state: selectedHub.state, + street: selectedHub.street, + country: selectedHub.country, + zipCode: selectedHub.zipCode, + ), + eventName: state.eventName, + vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, + roleRates: roleRates, + ); + await _createRecurringOrderUseCase(order); + emit(state.copyWith(status: RecurringOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: RecurringOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onInitialized( + RecurringOrderInitialized event, + Emitter emit, + ) async { + final Map data = event.data; + final String title = data['title']?.toString() ?? ''; + final DateTime? startDate = data['startDate'] as DateTime?; + final String? orderId = data['orderId']?.toString(); + + emit( + state.copyWith(eventName: title, startDate: startDate ?? DateTime.now()), + ); + + if (orderId == null || orderId.isEmpty) return; + + emit(state.copyWith(status: RecurringOrderStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + final domain.ReorderData orderDetails = + await _getOrderDetailsForReorderUseCase(orderId); + + // Map positions + final List positions = orderDetails.positions + .map((domain.ReorderPosition role) { + return RecurringOrderPosition( + role: role.roleId, + count: role.count, + startTime: role.startTime, + endTime: role.endTime, + lunchBreak: role.lunchBreak, + ); + }) + .toList(); + + // Update state with order details + final domain.Vendor? selectedVendor = state.vendors + .where((domain.Vendor v) => v.id == orderDetails.vendorId) + .firstOrNull; + + final RecurringOrderHubOption? selectedHub = state.hubs + .where( + (RecurringOrderHubOption h) => + h.placeId == orderDetails.hub.placeId, + ) + .firstOrNull; + + emit( + state.copyWith( + eventName: orderDetails.eventName.isNotEmpty + ? orderDetails.eventName + : title, + positions: positions, + selectedVendor: selectedVendor, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + status: RecurringOrderStatus.initial, + startDate: startDate ?? orderDetails.startDate ?? DateTime.now(), + endDate: orderDetails.endDate ?? DateTime.now(), + recurringDays: orderDetails.recurringDays, + ), + ); + + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + }, + onError: (String errorKey) => state.copyWith( + status: RecurringOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart new file mode 100644 index 00000000..779e97cf --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart @@ -0,0 +1,134 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'recurring_order_state.dart'; + +abstract class RecurringOrderEvent extends Equatable { + const RecurringOrderEvent(); + + @override + List get props => []; +} + +class RecurringOrderVendorsLoaded extends RecurringOrderEvent { + const RecurringOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class RecurringOrderVendorChanged extends RecurringOrderEvent { + const RecurringOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class RecurringOrderHubsLoaded extends RecurringOrderEvent { + const RecurringOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class RecurringOrderHubChanged extends RecurringOrderEvent { + const RecurringOrderHubChanged(this.hub); + + final RecurringOrderHubOption hub; + + @override + List get props => [hub]; +} + +class RecurringOrderEventNameChanged extends RecurringOrderEvent { + const RecurringOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class RecurringOrderStartDateChanged extends RecurringOrderEvent { + const RecurringOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderEndDateChanged extends RecurringOrderEvent { + const RecurringOrderEndDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderDayToggled extends RecurringOrderEvent { + const RecurringOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class RecurringOrderPositionAdded extends RecurringOrderEvent { + const RecurringOrderPositionAdded(); +} + +class RecurringOrderPositionRemoved extends RecurringOrderEvent { + const RecurringOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class RecurringOrderPositionUpdated extends RecurringOrderEvent { + const RecurringOrderPositionUpdated(this.index, this.position); + + final int index; + final RecurringOrderPosition position; + + @override + List get props => [index, position]; +} + +class RecurringOrderSubmitted extends RecurringOrderEvent { + const RecurringOrderSubmitted(); +} + +class RecurringOrderInitialized extends RecurringOrderEvent { + const RecurringOrderInitialized(this.data); + final Map data; + + @override + List get props => [data]; +} + +class RecurringOrderHubManagerChanged extends RecurringOrderEvent { + const RecurringOrderHubManagerChanged(this.manager); + final RecurringOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class RecurringOrderManagersLoaded extends RecurringOrderEvent { + const RecurringOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart new file mode 100644 index 00000000..8a22eb64 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -0,0 +1,254 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum RecurringOrderStatus { initial, loading, success, failure } + +class RecurringOrderState extends Equatable { + const RecurringOrderState({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.eventName, + required this.positions, + required this.autoSelectedDayIndex, + this.status = RecurringOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + this.managers = const [], + this.selectedManager, + }); + + factory RecurringOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return RecurringOrderState( + startDate: start, + endDate: start.add(const Duration(days: 7)), + recurringDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + RecurringOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + vendors: const [], + hubs: const [], + roles: const [], + managers: const [], + ); + } + + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final String location; + final String eventName; + final List positions; + final int? autoSelectedDayIndex; + final RecurringOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final RecurringOrderHubOption? selectedHub; + final List roles; + final List managers; + final RecurringOrderManagerOption? selectedManager; + + RecurringOrderState copyWith({ + DateTime? startDate, + DateTime? endDate, + List? recurringDays, + String? location, + String? eventName, + List? positions, + int? autoSelectedDayIndex, + RecurringOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + RecurringOrderHubOption? selectedHub, + List? roles, + List? managers, + RecurringOrderManagerOption? selectedManager, + }) { + return RecurringOrderState( + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + recurringDays: recurringDays ?? this.recurringDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, + hubs: hubs ?? this.hubs, + selectedHub: selectedHub ?? this.selectedHub, + roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, + ); + } + + bool get isValid { + final bool datesValid = !endDate.isBefore(startDate); + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + recurringDays.isNotEmpty && + datesValid && + positions.every( + (RecurringOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + eventName, + positions, + autoSelectedDayIndex, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + managers, + selectedManager, + ]; +} + +class RecurringOrderHubOption extends Equatable { + const RecurringOrderHubOption({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class RecurringOrderRoleOption extends Equatable { + const RecurringOrderRoleOption({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class RecurringOrderManagerOption extends Equatable { + const RecurringOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak, + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String? lunchBreak; + + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/create_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/create_order_page.dart new file mode 100644 index 00000000..7bc1f023 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/create_order_page.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import '../widgets/create_order/create_order_view.dart'; + +/// Main entry page for the client create order flow. +/// +/// This page displays the [CreateOrderView]. +/// It follows the Krow Clean Architecture by being a [StatelessWidget] and +/// delegating its UI to other components. +class ClientCreateOrderPage extends StatelessWidget { + /// Creates a [ClientCreateOrderPage]. + const ClientCreateOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + return const CreateOrderView(); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart new file mode 100644 index 00000000..8c8f0e3f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:client_orders_common/client_orders_common.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../blocs/one_time_order/one_time_order_bloc.dart'; +import '../blocs/one_time_order/one_time_order_event.dart'; +import '../blocs/one_time_order/one_time_order_state.dart'; + +/// Page for creating a one-time staffing order. +/// Users can specify the date, location, and multiple staff positions required. +/// +/// This page initializes the [OneTimeOrderBloc] and displays the [OneTimeOrderView] +/// from the common orders package. It follows the Krow Clean Architecture by being +/// a [StatelessWidget] and mapping local BLoC state to generic UI models. +class OneTimeOrderPage extends StatelessWidget { + /// Creates a [OneTimeOrderPage]. + const OneTimeOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) { + final OneTimeOrderBloc bloc = Modular.get(); + final dynamic args = Modular.args.data; + if (args is Map) { + bloc.add(OneTimeOrderInitialized(args)); + } + return bloc; + }, + child: BlocBuilder( + builder: (BuildContext context, OneTimeOrderState state) { + final OneTimeOrderBloc bloc = BlocProvider.of( + context, + ); + + return OneTimeOrderView( + status: _mapStatus(state.status), + errorMessage: state.errorMessage, + eventName: state.eventName, + selectedVendor: state.selectedVendor, + vendors: state.vendors, + date: state.date, + selectedHub: state.selectedHub != null + ? _mapHub(state.selectedHub!) + : null, + hubs: state.hubs.map(_mapHub).toList(), + positions: state.positions.map(_mapPosition).toList(), + roles: state.roles.map(_mapRole).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, + hubManagers: state.managers.map(_mapManager).toList(), + isValid: state.isValid, + onEventNameChanged: (String val) => + bloc.add(OneTimeOrderEventNameChanged(val)), + onVendorChanged: (Vendor val) => + bloc.add(OneTimeOrderVendorChanged(val)), + onDateChanged: (DateTime val) => + bloc.add(OneTimeOrderDateChanged(val)), + onHubChanged: (OrderHubUiModel val) { + final OneTimeOrderHubOption originalHub = state.hubs.firstWhere( + (OneTimeOrderHubOption h) => h.id == val.id, + ); + bloc.add(OneTimeOrderHubChanged(originalHub)); + }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const OneTimeOrderHubManagerChanged(null)); + return; + } + final OneTimeOrderManagerOption original = + state.managers.firstWhere( + (OneTimeOrderManagerOption m) => m.id == val.id, + ); + bloc.add(OneTimeOrderHubManagerChanged(original)); + }, + onPositionAdded: () => bloc.add(const OneTimeOrderPositionAdded()), + onPositionUpdated: (int index, OrderPositionUiModel val) { + final OneTimeOrderPosition original = state.positions[index]; + final OneTimeOrderPosition updated = original.copyWith( + role: val.role, + count: val.count, + startTime: val.startTime, + endTime: val.endTime, + lunchBreak: val.lunchBreak, + ); + bloc.add(OneTimeOrderPositionUpdated(index, updated)); + }, + onPositionRemoved: (int index) => + bloc.add(OneTimeOrderPositionRemoved(index)), + onSubmit: () => bloc.add(const OneTimeOrderSubmitted()), + onDone: () => Modular.to.toOrdersSpecificDate(state.date), + onBack: () => Modular.to.pop(), + ); + }, + ), + ); + } + + OrderFormStatus _mapStatus(OneTimeOrderStatus status) { + switch (status) { + case OneTimeOrderStatus.initial: + return OrderFormStatus.initial; + case OneTimeOrderStatus.loading: + return OrderFormStatus.loading; + case OneTimeOrderStatus.success: + return OrderFormStatus.success; + case OneTimeOrderStatus.failure: + return OrderFormStatus.failure; + } + } + + OrderHubUiModel _mapHub(OneTimeOrderHubOption hub) { + return OrderHubUiModel( + id: hub.id, + name: hub.name, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ); + } + + OrderRoleUiModel _mapRole(OneTimeOrderRoleOption role) { + return OrderRoleUiModel( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ); + } + + OrderPositionUiModel _mapPosition(OneTimeOrderPosition pos) { + return OrderPositionUiModel( + role: pos.role, + count: pos.count, + startTime: pos.startTime, + endTime: pos.endTime, + lunchBreak: pos.lunchBreak, + ); + } + + OrderManagerUiModel _mapManager(OneTimeOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart new file mode 100644 index 00000000..26109e7a --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:client_orders_common/client_orders_common.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition; +import '../blocs/permanent_order/permanent_order_bloc.dart'; +import '../blocs/permanent_order/permanent_order_event.dart'; +import '../blocs/permanent_order/permanent_order_state.dart'; + +/// Page for creating a permanent staffing order. +class PermanentOrderPage extends StatelessWidget { + /// Creates a [PermanentOrderPage]. + const PermanentOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) { + final PermanentOrderBloc bloc = Modular.get(); + final dynamic args = Modular.args.data; + if (args is Map) { + bloc.add(PermanentOrderInitialized(args)); + } + return bloc; + }, + child: BlocBuilder( + builder: (BuildContext context, PermanentOrderState state) { + final PermanentOrderBloc bloc = BlocProvider.of( + context, + ); + + return PermanentOrderView( + status: _mapStatus(state.status), + errorMessage: state.errorMessage, + eventName: state.eventName, + selectedVendor: state.selectedVendor, + vendors: state.vendors, + startDate: state.startDate, + permanentDays: state.permanentDays, + selectedHub: state.selectedHub != null + ? _mapHub(state.selectedHub!) + : null, + hubs: state.hubs.map(_mapHub).toList(), + hubManagers: state.managers.map(_mapManager).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, + positions: state.positions.map(_mapPosition).toList(), + roles: state.roles.map(_mapRole).toList(), + isValid: state.isValid, + onEventNameChanged: (String val) => + bloc.add(PermanentOrderEventNameChanged(val)), + onVendorChanged: (Vendor val) => + bloc.add(PermanentOrderVendorChanged(val)), + onStartDateChanged: (DateTime val) => + bloc.add(PermanentOrderStartDateChanged(val)), + onDayToggled: (int index) => + bloc.add(PermanentOrderDayToggled(index)), + onHubChanged: (OrderHubUiModel val) { + final PermanentOrderHubOption originalHub = state.hubs.firstWhere( + (PermanentOrderHubOption h) => h.id == val.id, + ); + bloc.add(PermanentOrderHubChanged(originalHub)); + }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const PermanentOrderHubManagerChanged(null)); + return; + } + final PermanentOrderManagerOption original = + state.managers.firstWhere( + (PermanentOrderManagerOption m) => m.id == val.id, + ); + bloc.add(PermanentOrderHubManagerChanged(original)); + }, + onPositionAdded: () => + bloc.add(const PermanentOrderPositionAdded()), + onPositionUpdated: (int index, OrderPositionUiModel val) { + final PermanentOrderPosition original = state.positions[index]; + final PermanentOrderPosition updated = original.copyWith( + role: val.role, + count: val.count, + startTime: val.startTime, + endTime: val.endTime, + lunchBreak: val.lunchBreak, + ); + bloc.add(PermanentOrderPositionUpdated(index, updated)); + }, + onPositionRemoved: (int index) => + bloc.add(PermanentOrderPositionRemoved(index)), + onSubmit: () => bloc.add(const PermanentOrderSubmitted()), + onDone: () { + final DateTime initialDate = _firstPermanentShiftDate( + state.startDate, + state.permanentDays, + ); + + // Navigate to orders page with the initial date set to the first recurring shift date + Modular.to.toOrdersSpecificDate(initialDate); + }, + onBack: () => Modular.to.pop(), + ); + }, + ), + ); + } + + DateTime _firstPermanentShiftDate( + DateTime startDate, + List permanentDays, + ) { + final DateTime start = DateTime( + startDate.year, + startDate.month, + startDate.day, + ); + final DateTime end = start.add(const Duration(days: 29)); + final Set selected = permanentDays.toSet(); + for ( + DateTime day = start; + !day.isAfter(end); + day = day.add(const Duration(days: 1)) + ) { + if (selected.contains(_weekdayLabel(day))) { + return day; + } + } + return start; + } + + String _weekdayLabel(DateTime date) { + switch (date.weekday) { + case DateTime.monday: + return 'MON'; + case DateTime.tuesday: + return 'TUE'; + case DateTime.wednesday: + return 'WED'; + case DateTime.thursday: + return 'THU'; + case DateTime.friday: + return 'FRI'; + case DateTime.saturday: + return 'SAT'; + case DateTime.sunday: + return 'SUN'; + default: + return 'SUN'; + } + } + + OrderFormStatus _mapStatus(PermanentOrderStatus status) { + switch (status) { + case PermanentOrderStatus.initial: + return OrderFormStatus.initial; + case PermanentOrderStatus.loading: + return OrderFormStatus.loading; + case PermanentOrderStatus.success: + return OrderFormStatus.success; + case PermanentOrderStatus.failure: + return OrderFormStatus.failure; + } + } + + OrderHubUiModel _mapHub(PermanentOrderHubOption hub) { + return OrderHubUiModel( + id: hub.id, + name: hub.name, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ); + } + + OrderRoleUiModel _mapRole(PermanentOrderRoleOption role) { + return OrderRoleUiModel( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ); + } + + OrderPositionUiModel _mapPosition(PermanentOrderPosition pos) { + return OrderPositionUiModel( + role: pos.role, + count: pos.count, + startTime: pos.startTime, + endTime: pos.endTime, + lunchBreak: pos.lunchBreak ?? 'NO_BREAK', + ); + } + + OrderManagerUiModel _mapManager(PermanentOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/rapid_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/rapid_order_page.dart similarity index 94% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/rapid_order_page.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/rapid_order_page.dart index 2bb444cf..46ea23f8 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/rapid_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/rapid_order_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/rapid_order_bloc.dart'; +import '../blocs/rapid_order/rapid_order_bloc.dart'; import '../widgets/rapid_order/rapid_order_view.dart'; /// Rapid Order Flow Page - Emergency staffing requests. diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart new file mode 100644 index 00000000..c65c26a3 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:client_orders_common/client_orders_common.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition; +import '../blocs/recurring_order/recurring_order_bloc.dart'; +import '../blocs/recurring_order/recurring_order_event.dart'; +import '../blocs/recurring_order/recurring_order_state.dart'; + +/// Page for creating a recurring staffing order. +class RecurringOrderPage extends StatelessWidget { + /// Creates a [RecurringOrderPage]. + const RecurringOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) { + final RecurringOrderBloc bloc = Modular.get(); + final dynamic args = Modular.args.data; + if (args is Map) { + bloc.add(RecurringOrderInitialized(args)); + } + return bloc; + }, + child: BlocBuilder( + builder: (BuildContext context, RecurringOrderState state) { + final RecurringOrderBloc bloc = BlocProvider.of( + context, + ); + + return RecurringOrderView( + status: _mapStatus(state.status), + errorMessage: state.errorMessage, + eventName: state.eventName, + selectedVendor: state.selectedVendor, + vendors: state.vendors, + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.recurringDays, + selectedHub: state.selectedHub != null + ? _mapHub(state.selectedHub!) + : null, + hubs: state.hubs.map(_mapHub).toList(), + hubManagers: state.managers.map(_mapManager).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, + positions: state.positions.map(_mapPosition).toList(), + roles: state.roles.map(_mapRole).toList(), + isValid: state.isValid, + onEventNameChanged: (String val) => + bloc.add(RecurringOrderEventNameChanged(val)), + onVendorChanged: (Vendor val) => + bloc.add(RecurringOrderVendorChanged(val)), + onStartDateChanged: (DateTime val) => + bloc.add(RecurringOrderStartDateChanged(val)), + onEndDateChanged: (DateTime val) => + bloc.add(RecurringOrderEndDateChanged(val)), + onDayToggled: (int index) => + bloc.add(RecurringOrderDayToggled(index)), + onHubChanged: (OrderHubUiModel val) { + final RecurringOrderHubOption originalHub = state.hubs.firstWhere( + (RecurringOrderHubOption h) => h.id == val.id, + ); + bloc.add(RecurringOrderHubChanged(originalHub)); + }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const RecurringOrderHubManagerChanged(null)); + return; + } + final RecurringOrderManagerOption original = + state.managers.firstWhere( + (RecurringOrderManagerOption m) => m.id == val.id, + ); + bloc.add(RecurringOrderHubManagerChanged(original)); + }, + onPositionAdded: () => + bloc.add(const RecurringOrderPositionAdded()), + onPositionUpdated: (int index, OrderPositionUiModel val) { + final RecurringOrderPosition original = state.positions[index]; + final RecurringOrderPosition updated = original.copyWith( + role: val.role, + count: val.count, + startTime: val.startTime, + endTime: val.endTime, + lunchBreak: val.lunchBreak, + ); + bloc.add(RecurringOrderPositionUpdated(index, updated)); + }, + onPositionRemoved: (int index) => + bloc.add(RecurringOrderPositionRemoved(index)), + onSubmit: () => bloc.add(const RecurringOrderSubmitted()), + onDone: () { + final DateTime maxEndDate = state.startDate.add( + const Duration(days: 29), + ); + final DateTime effectiveEndDate = + state.endDate.isAfter(maxEndDate) + ? maxEndDate + : state.endDate; + final DateTime initialDate = _firstRecurringShiftDate( + state.startDate, + effectiveEndDate, + state.recurringDays, + ); + + // Navigate to orders page with the initial date set to the first recurring shift date + Modular.to.toOrdersSpecificDate(initialDate); + }, + onBack: () => Modular.to.pop(), + ); + }, + ), + ); + } + + DateTime _firstRecurringShiftDate( + DateTime startDate, + DateTime endDate, + List recurringDays, + ) { + final DateTime start = DateTime( + startDate.year, + startDate.month, + startDate.day, + ); + final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); + final Set selected = recurringDays.toSet(); + for ( + DateTime day = start; + !day.isAfter(end); + day = day.add(const Duration(days: 1)) + ) { + if (selected.contains(_weekdayLabel(day))) { + return day; + } + } + return start; + } + + String _weekdayLabel(DateTime date) { + switch (date.weekday) { + case DateTime.monday: + return 'MON'; + case DateTime.tuesday: + return 'TUE'; + case DateTime.wednesday: + return 'WED'; + case DateTime.thursday: + return 'THU'; + case DateTime.friday: + return 'FRI'; + case DateTime.saturday: + return 'SAT'; + case DateTime.sunday: + return 'SUN'; + default: + return 'SUN'; + } + } + + OrderFormStatus _mapStatus(RecurringOrderStatus status) { + switch (status) { + case RecurringOrderStatus.initial: + return OrderFormStatus.initial; + case RecurringOrderStatus.loading: + return OrderFormStatus.loading; + case RecurringOrderStatus.success: + return OrderFormStatus.success; + case RecurringOrderStatus.failure: + return OrderFormStatus.failure; + } + } + + OrderHubUiModel _mapHub(RecurringOrderHubOption hub) { + return OrderHubUiModel( + id: hub.id, + name: hub.name, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ); + } + + OrderRoleUiModel _mapRole(RecurringOrderRoleOption role) { + return OrderRoleUiModel( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ); + } + + OrderPositionUiModel _mapPosition(RecurringOrderPosition pos) { + return OrderPositionUiModel( + role: pos.role, + count: pos.count, + startTime: pos.startTime, + endTime: pos.endTime, + lunchBreak: pos.lunchBreak ?? 'NO_BREAK', + ); + } + + OrderManagerUiModel _mapManager(RecurringOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart new file mode 100644 index 00000000..68b48b75 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart @@ -0,0 +1,37 @@ +class UiOrderType { + const UiOrderType({ + required this.id, + required this.titleKey, + required this.descriptionKey, + }); + + final String id; + final String titleKey; + final String descriptionKey; +} + +/// Order type constants for the create order feature +const List orderTypes = [ + /// TODO: FEATURE_NOT_YET_IMPLEMENTED + // UiOrderType( + // id: 'rapid', + // titleKey: 'client_create_order.types.rapid', + // descriptionKey: 'client_create_order.types.rapid_desc', + // ), + UiOrderType( + id: 'one-time', + titleKey: 'client_create_order.types.one_time', + descriptionKey: 'client_create_order.types.one_time_desc', + ), + + UiOrderType( + id: 'recurring', + titleKey: 'client_create_order.types.recurring', + descriptionKey: 'client_create_order.types.recurring_desc', + ), + UiOrderType( + id: 'permanent', + titleKey: 'client_create_order.types.permanent', + descriptionKey: 'client_create_order.types.permanent_desc', + ), +]; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/ui_entities/order_type_ui_metadata.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/ui_entities/order_type_ui_metadata.dart similarity index 55% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/ui_entities/order_type_ui_metadata.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/ui_entities/order_type_ui_metadata.dart index 0729f4a1..c6ee52b7 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/ui_entities/order_type_ui_metadata.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/ui_entities/order_type_ui_metadata.dart @@ -18,44 +18,44 @@ class OrderTypeUiMetadata { factory OrderTypeUiMetadata.fromId({required String id}) { switch (id) { case 'rapid': - return const OrderTypeUiMetadata( + return OrderTypeUiMetadata( icon: UiIcons.zap, - backgroundColor: UiColors.tagPending, - borderColor: UiColors.separatorSpecial, - iconBackgroundColor: UiColors.textWarning, - iconColor: UiColors.white, - textColor: UiColors.textWarning, - descriptionColor: UiColors.textWarning, + backgroundColor: UiColors.iconError.withAlpha(24), + borderColor: UiColors.iconError, + iconBackgroundColor: UiColors.iconError.withAlpha(24), + iconColor: UiColors.iconError, + textColor: UiColors.iconError, + descriptionColor: UiColors.iconError, ); case 'one-time': - return const OrderTypeUiMetadata( + return OrderTypeUiMetadata( icon: UiIcons.calendar, - backgroundColor: UiColors.tagInProgress, - borderColor: UiColors.primaryInverse, - iconBackgroundColor: UiColors.primary, - iconColor: UiColors.white, - textColor: UiColors.textLink, - descriptionColor: UiColors.textLink, + backgroundColor: UiColors.primary.withAlpha(24), + borderColor: UiColors.primary, + iconBackgroundColor: UiColors.primary.withAlpha(24), + iconColor: UiColors.primary, + textColor: UiColors.primary, + descriptionColor: UiColors.primary, ); - case 'recurring': - return const OrderTypeUiMetadata( - icon: UiIcons.rotateCcw, - backgroundColor: UiColors.tagSuccess, - borderColor: UiColors.switchActive, - iconBackgroundColor: UiColors.textSuccess, - iconColor: UiColors.white, + case 'permanent': + return OrderTypeUiMetadata( + icon: UiIcons.users, + backgroundColor: UiColors.textSuccess.withAlpha(24), + borderColor: UiColors.textSuccess, + iconBackgroundColor: UiColors.textSuccess.withAlpha(24), + iconColor: UiColors.textSuccess, textColor: UiColors.textSuccess, descriptionColor: UiColors.textSuccess, ); - case 'permanent': - return const OrderTypeUiMetadata( - icon: UiIcons.briefcase, - backgroundColor: UiColors.tagRefunded, - borderColor: UiColors.primaryInverse, - iconBackgroundColor: UiColors.primary, - iconColor: UiColors.white, - textColor: UiColors.textLink, - descriptionColor: UiColors.textLink, + case 'recurring': + return OrderTypeUiMetadata( + icon: UiIcons.rotateCcw, + backgroundColor: const Color.fromARGB(255, 170, 10, 223).withAlpha(24), + borderColor: const Color.fromARGB(255, 170, 10, 223), + iconBackgroundColor: const Color.fromARGB(255, 170, 10, 223).withAlpha(24), + iconColor: const Color.fromARGB(255, 170, 10, 223), + textColor: const Color.fromARGB(255, 170, 10, 223), + descriptionColor: const Color.fromARGB(255, 170, 10, 223), ); default: return const OrderTypeUiMetadata( diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart new file mode 100644 index 00000000..0c39efdd --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart @@ -0,0 +1,112 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../utils/constants/order_types.dart'; +import '../../utils/ui_entities/order_type_ui_metadata.dart'; +import '../order_type_card.dart'; + +/// Helper to map keys to localized strings. +String _getTranslation({required String key}) { + if (key == 'client_create_order.types.rapid') { + return t.client_create_order.types.rapid; + } else if (key == 'client_create_order.types.rapid_desc') { + return t.client_create_order.types.rapid_desc; + } else if (key == 'client_create_order.types.one_time') { + return t.client_create_order.types.one_time; + } else if (key == 'client_create_order.types.one_time_desc') { + return t.client_create_order.types.one_time_desc; + } else if (key == 'client_create_order.types.recurring') { + return t.client_create_order.types.recurring; + } else if (key == 'client_create_order.types.recurring_desc') { + return t.client_create_order.types.recurring_desc; + } else if (key == 'client_create_order.types.permanent') { + return t.client_create_order.types.permanent; + } else if (key == 'client_create_order.types.permanent_desc') { + return t.client_create_order.types.permanent_desc; + } + return key; +} + +/// The main content of the Create Order page. +class CreateOrderView extends StatelessWidget { + /// Creates a [CreateOrderView]. + const CreateOrderView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.client_create_order.title, + onLeadingPressed: () => Modular.to.toClientHome(), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space6), + child: Text( + t.client_create_order.section_title, + style: UiTypography.body2m.textDescription, + ), + ), + Expanded( + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: UiConstants.space4, + crossAxisSpacing: UiConstants.space4, + childAspectRatio: 1, + ), + itemCount: orderTypes.length, + itemBuilder: (BuildContext context, int index) { + final UiOrderType type = orderTypes[index]; + final OrderTypeUiMetadata ui = OrderTypeUiMetadata.fromId( + id: type.id, + ); + + return OrderTypeCard( + icon: ui.icon, + title: _getTranslation(key: type.titleKey), + description: _getTranslation(key: type.descriptionKey), + backgroundColor: ui.backgroundColor, + borderColor: ui.borderColor, + iconBackgroundColor: ui.iconBackgroundColor, + iconColor: ui.iconColor, + textColor: ui.textColor, + descriptionColor: ui.descriptionColor, + onTap: () { + switch (type.id) { + case 'rapid': + Modular.to.toCreateOrderRapid(); + break; + case 'one-time': + Modular.to.toCreateOrderOneTime(); + break; + case 'recurring': + Modular.to.toCreateOrderRecurring(); + break; + case 'permanent': + Modular.to.toCreateOrderPermanent(); + break; + } + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/order_type_card.dart similarity index 93% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/order_type_card.dart index f9c92f43..3229daf1 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/order_type_card.dart @@ -57,7 +57,7 @@ class OrderTypeCard extends StatelessWidget { decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: borderColor, width: 2), + border: Border.all(color: borderColor, width: 0.75), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -73,8 +73,7 @@ class OrderTypeCard extends StatelessWidget { ), child: Icon(icon, color: iconColor, size: 24), ), - Text(title, style: UiTypography.body2b.copyWith(color: textColor)), - const SizedBox(height: UiConstants.space1), + Text(title, style: UiTypography.body1b.copyWith(color: textColor)), Expanded( child: Text( description, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart similarity index 98% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart rename to apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index da6a5df4..08837105 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -5,9 +5,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; -import '../../blocs/rapid_order_bloc.dart'; -import '../../blocs/rapid_order_event.dart'; -import '../../blocs/rapid_order_state.dart'; +import '../../blocs/rapid_order/rapid_order_bloc.dart'; +import '../../blocs/rapid_order/rapid_order_event.dart'; +import '../../blocs/rapid_order/rapid_order_state.dart'; import 'rapid_order_example_card.dart'; import 'rapid_order_header.dart'; import 'rapid_order_success_view.dart'; @@ -295,7 +295,6 @@ class _RapidOrderActions extends StatelessWidget { onPressed: isSubmitting || isMessageEmpty ? null : () { - print('RapidOrder send pressed'); BlocProvider.of( context, ).add(const RapidOrderSubmitted()); diff --git a/apps/mobile/packages/features/client/create_order/pubspec.yaml b/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml similarity index 69% rename from apps/mobile/packages/features/client/create_order/pubspec.yaml rename to apps/mobile/packages/features/client/orders/create_order/pubspec.yaml index b1091732..20a70779 100644 --- a/apps/mobile/packages/features/client/create_order/pubspec.yaml +++ b/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml @@ -15,15 +15,17 @@ dependencies: equatable: ^2.0.5 intl: 0.20.2 design_system: - path: ../../../design_system + path: ../../../../design_system core_localization: - path: ../../../core_localization + path: ../../../../core_localization krow_domain: - path: ../../../domain + path: ../../../../domain krow_core: - path: ../../../core + path: ../../../../core krow_data_connect: - path: ../../../data_connect + path: ../../../../data_connect + client_orders_common: + path: ../orders_common firebase_data_connect: ^0.2.2+2 firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/features/client/orders/orders_common/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/mobile/packages/features/client/orders/orders_common/.metadata b/apps/mobile/packages/features/client/orders/orders_common/.metadata new file mode 100644 index 00000000..08c24780 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: android + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: ios + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: linux + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: macos + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: web + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: windows + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/mobile/packages/features/client/orders/orders_common/README.md b/apps/mobile/packages/features/client/orders/orders_common/README.md new file mode 100644 index 00000000..7cb622a2 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/README.md @@ -0,0 +1,3 @@ +# orders + +A new Flutter project. diff --git a/apps/mobile/packages/features/client/orders/orders_common/analysis_options.yaml b/apps/mobile/packages/features/client/orders/orders_common/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/build.gradle.kts b/apps/mobile/packages/features/client/orders/orders_common/android/app/build.gradle.kts new file mode 100644 index 00000000..90fe90fe --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.orders" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.orders" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/debug/AndroidManifest.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/AndroidManifest.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b5ce4db1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/kotlin/com/example/orders/MainActivity.kt b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/kotlin/com/example/orders/MainActivity.kt new file mode 100644 index 00000000..35c65d09 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/kotlin/com/example/orders/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.orders + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values-night/styles.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values/styles.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/app/src/profile/AndroidManifest.xml b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/build.gradle.kts b/apps/mobile/packages/features/client/orders/orders_common/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/gradle.properties b/apps/mobile/packages/features/client/orders/orders_common/android/gradle.properties new file mode 100644 index 00000000..fbee1d8c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/packages/features/client/orders/orders_common/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e4ef43fb --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/mobile/packages/features/client/orders/orders_common/android/settings.gradle.kts b/apps/mobile/packages/features/client/orders/orders_common/android/settings.gradle.kts new file mode 100644 index 00000000..ca7fe065 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Debug.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Release.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Podfile b/apps/mobile/packages/features/client/orders/orders_common/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..127c2c37 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/AppDelegate.swift b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Info.plist b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Info.plist new file mode 100644 index 00000000..29679a5a --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Orders + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + orders + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Runner-Bridging-Header.h b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/mobile/packages/features/client/orders/orders_common/ios/RunnerTests/RunnerTests.swift b/apps/mobile/packages/features/client/orders/orders_common/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart new file mode 100644 index 00000000..410be326 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -0,0 +1,30 @@ +// UI Models +export 'src/presentation/widgets/order_ui_models.dart'; + +// One Time Order Widgets +export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_header.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_success_view.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_view.dart'; + +// Permanent Order Widgets +export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_header.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_view.dart'; + +// Recurring Order Widgets +export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_header.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_view.dart'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart new file mode 100644 index 00000000..185b9bef --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart @@ -0,0 +1,167 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'order_ui_models.dart'; + +class HubManagerSelector extends StatelessWidget { + const HubManagerSelector({ + required this.managers, + required this.selectedManager, + required this.onChanged, + required this.hintText, + required this.label, + this.description, + this.noManagersText, + this.noneText, + super.key, + }); + + final List managers; + final OrderManagerUiModel? selectedManager; + final ValueChanged onChanged; + final String hintText; + final String label; + final String? description; + final String? noManagersText; + final String? noneText; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + style: UiTypography.body1m.textPrimary, + ), + if (description != null) ...[ + const SizedBox(height: UiConstants.space2), + Text(description!, style: UiTypography.body2r.textSecondary), + ], + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: () => _showSelector(context), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: selectedManager != null ? UiColors.primary : UiColors.border, + width: selectedManager != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + UiIcons.user, + color: selectedManager != null + ? UiColors.primary + : UiColors.iconSecondary, + size: 20, + ), + const SizedBox(width: UiConstants.space3), + Text( + selectedManager?.name ?? hintText, + style: selectedManager != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + Future _showSelector(BuildContext context) async { + final OrderManagerUiModel? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + shrinkWrap: true, + itemCount: managers.isEmpty ? 2 : managers.length + 1, + itemBuilder: (BuildContext context, int index) { + final String emptyText = noManagersText ?? 'No hub managers available'; + final String noneLabel = noneText ?? 'None'; + if (managers.isEmpty) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(emptyText), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + if (index == managers.length) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + final OrderManagerUiModel manager = managers[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + title: Text(manager.name, style: UiTypography.body1m.textPrimary), + subtitle: manager.phone != null + ? Text(manager.phone!, style: UiTypography.body2r.textSecondary) + : null, + onTap: () => Navigator.of(context).pop(manager), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + if (selected.id == 'NONE') { + onChanged(null); + } else { + onChanged(selected); + } + } + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart similarity index 90% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart index babb3e06..b59f81ec 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart @@ -1,13 +1,10 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../../blocs/one_time_order_state.dart'; +import '../order_ui_models.dart'; /// A card widget for editing a specific position in a one-time order. -/// Matches the prototype layout while using design system tokens. class OneTimeOrderPositionCard extends StatelessWidget { - /// Creates a [OneTimeOrderPositionCard]. const OneTimeOrderPositionCard({ required this.index, required this.position, @@ -24,41 +21,18 @@ class OneTimeOrderPositionCard extends StatelessWidget { super.key, }); - /// The index of the position in the list. final int index; - - /// The position entity data. - final OneTimeOrderPosition position; - - /// Whether this position can be removed (usually if there's more than one). + final OrderPositionUiModel position; final bool isRemovable; - - /// Callback when the position data is updated. - final ValueChanged onUpdated; - - /// Callback when the position is removed. + final ValueChanged onUpdated; final VoidCallback onRemoved; - - /// Label for positions (e.g., "Position"). final String positionLabel; - - /// Label for the role selection. final String roleLabel; - - /// Label for the worker count. final String workersLabel; - - /// Label for the start time. final String startLabel; - - /// Label for the end time. final String endLabel; - - /// Label for the lunch break. final String lunchLabel; - - /// Available roles for the selected vendor. - final List roles; + final List roles; @override Widget build(BuildContext context) { @@ -250,9 +224,7 @@ class OneTimeOrderPositionCard extends StatelessWidget { 'MIN_30', 'MIN_45', 'MIN_60', - ].map(( - String value, - ) { + ].map((String value) { final String label = switch (value) { 'NO_BREAK' => 'No Break', 'MIN_10' => '10 min (Paid)', @@ -321,7 +293,7 @@ class OneTimeOrderPositionCard extends StatelessWidget { List> _buildRoleItems() { final List> items = roles .map( - (OneTimeOrderRoleOption role) => DropdownMenuItem( + (OrderRoleUiModel role) => DropdownMenuItem( value: role.id, child: Text( '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', @@ -331,7 +303,8 @@ class OneTimeOrderPositionCard extends StatelessWidget { ) .toList(); - final bool hasSelected = roles.any((OneTimeOrderRoleOption role) => role.id == position.role); + final bool hasSelected = + roles.any((OrderRoleUiModel role) => role.id == position.role); if (position.role.isNotEmpty && !hasSelected) { items.add( DropdownMenuItem( diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart similarity index 100% rename from apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart rename to apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart new file mode 100644 index 00000000..4abe0eae --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -0,0 +1,416 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; +import 'one_time_order_date_picker.dart'; +import 'one_time_order_event_name_input.dart'; +import 'one_time_order_header.dart'; +import 'one_time_order_position_card.dart'; +import 'one_time_order_section_header.dart'; +import 'one_time_order_success_view.dart'; + +/// The main content of the One-Time Order page as a dumb widget. +class OneTimeOrderView extends StatelessWidget { + const OneTimeOrderView({ + required this.status, + required this.errorMessage, + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.date, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.hubManagers, + required this.selectedHubManager, + required this.isValid, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onDateChanged, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.onSubmit, + required this.onDone, + required this.onBack, + super.key, + }); + + final OrderFormStatus status; + final String? errorMessage; + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime date; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onDateChanged; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + final VoidCallback onSubmit; + final VoidCallback onDone; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn labels = + t.client_create_order.one_time; + + // React to error messages + if (status == OrderFormStatus.failure && errorMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + UiSnackbar.show( + context, + message: translateErrorKey(errorMessage!), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + }); + } + + if (status == OrderFormStatus.success) { + return OneTimeOrderSuccessView( + title: labels.success_title, + message: labels.success_message, + buttonLabel: labels.back_to_orders, + onDone: onDone, + ); + } + + if (vendors.isEmpty && status != OrderFormStatus.loading) { + return Scaffold( + body: Column( + children: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _OneTimeOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + date: date, + selectedHub: selectedHub, + hubs: hubs, + selectedHubManager: selectedHubManager, + hubManagers: hubManagers, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onDateChanged: onDateChanged, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: status == OrderFormStatus.loading + ? labels.creating + : labels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, + ), + ], + ), + ); + } +} + +class _OneTimeOrderForm extends StatelessWidget { + const _OneTimeOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.date, + required this.selectedHub, + required this.hubs, + required this.selectedHubManager, + required this.hubManagers, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onDateChanged, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + }); + + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime date; + final OrderHubUiModel? selectedHub; + final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onDateChanged; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn labels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.create_your_order, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + OneTimeOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + OneTimeOrderDatePicker( + label: labels.date_label, + value: date, + onChanged: onDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: labels.hub_manager_label, + description: labels.hub_manager_desc, + hintText: labels.hub_manager_hint, + noManagersText: labels.hub_manager_empty, + noneText: labels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + OneTimeOrderSectionHeader( + title: labels.positions_title, + actionLabel: labels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: OneTimeOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: labels.positions_title, + roleLabel: labels.select_role, + workersLabel: labels.workers_label, + startLabel: labels.start_label, + endLabel: labels.end_label, + lunchLabel: labels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart new file mode 100644 index 00000000..ea6680af --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart @@ -0,0 +1,112 @@ +import 'package:equatable/equatable.dart'; + +enum OrderFormStatus { initial, loading, success, failure } + +class OrderHubUiModel extends Equatable { + const OrderHubUiModel({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class OrderRoleUiModel extends Equatable { + const OrderRoleUiModel({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class OrderPositionUiModel extends Equatable { + const OrderPositionUiModel({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String lunchBreak; + + OrderPositionUiModel copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return OrderPositionUiModel( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} + +class OrderManagerUiModel extends Equatable { + const OrderManagerUiModel({ + required this.id, + required this.name, + this.phone, + }); + + final String id; + final String name; + final String? phone; + + @override + List get props => [id, name, phone]; +} + diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart new file mode 100644 index 00000000..7fe41016 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart @@ -0,0 +1,74 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A date picker field for the permanent order form. +class PermanentOrderDatePicker extends StatefulWidget { + /// Creates a [PermanentOrderDatePicker]. + const PermanentOrderDatePicker({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The label text to display above the field. + final String label; + + /// The currently selected date. + final DateTime value; + + /// Callback when a new date is selected. + final ValueChanged onChanged; + + @override + State createState() => + _PermanentOrderDatePickerState(); +} + +class _PermanentOrderDatePickerState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(widget.value), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(PermanentOrderDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _controller.text = DateFormat('yyyy-MM-dd').format(widget.value); + } + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + readOnly: true, + prefixIcon: UiIcons.calendar, + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: widget.value, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + widget.onChanged(picked); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart new file mode 100644 index 00000000..4eb0baa4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A text input for the order name in the permanent order form. +class PermanentOrderEventNameInput extends StatefulWidget { + const PermanentOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _PermanentOrderEventNameInputState(); +} + +class _PermanentOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(PermanentOrderEventNameInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + onChanged: widget.onChanged, + hintText: 'Order name', + prefixIcon: UiIcons.briefcase, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart new file mode 100644 index 00000000..8943f5f1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the permanent order flow with a colored background. +class PermanentOrderHeader extends StatelessWidget { + /// Creates a [PermanentOrderHeader]. + const PermanentOrderHeader({ + required this.title, + required this.subtitle, + required this.onBack, + super.key, + }); + + /// The title of the page. + final String title; + + /// The subtitle or description. + final String subtitle; + + /// Callback when the back button is pressed. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + UiConstants.space5, + bottom: UiConstants.space5, + left: UiConstants.space5, + right: UiConstants.space5, + ), + color: UiColors.primary, + child: Row( + children: [ + GestureDetector( + onTap: onBack, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: const Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart new file mode 100644 index 00000000..25b9b02f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart @@ -0,0 +1,321 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../order_ui_models.dart'; + +/// A card widget for editing a specific position in a permanent order. +class PermanentOrderPositionCard extends StatelessWidget { + const PermanentOrderPositionCard({ + required this.index, + required this.position, + required this.isRemovable, + required this.onUpdated, + required this.onRemoved, + required this.positionLabel, + required this.roleLabel, + required this.workersLabel, + required this.startLabel, + required this.endLabel, + required this.lunchLabel, + required this.roles, + super.key, + }); + + final int index; + final OrderPositionUiModel position; + final bool isRemovable; + final ValueChanged onUpdated; + final VoidCallback onRemoved; + final String positionLabel; + final String roleLabel; + final String workersLabel; + final String startLabel; + final String endLabel; + final String lunchLabel; + final List roles; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$positionLabel #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (isRemovable) + GestureDetector( + onTap: onRemoved, + child: Text( + t.client_create_order.one_time.remove, + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Role (Dropdown) + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), + value: position.role.isEmpty ? null : position.role, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(role: val)); + } + }, + items: _buildRoleItems(), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + + // Start/End/Workers Row + Row( + children: [ + // Start Time + Expanded( + child: _buildTimeInput( + context: context, + label: startLabel, + value: position.startTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(startTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // End Time + Expanded( + child: _buildTimeInput( + context: context, + label: endLabel, + value: position.endTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(endTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // Workers Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workersLabel, + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Container( + height: 40, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${position.count}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Lunch Break + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: position.lunchBreak, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(lunchBreak: val)); + } + }, + items: [ + 'NO_BREAK', + 'MIN_10', + 'MIN_15', + 'MIN_30', + 'MIN_45', + 'MIN_60', + ].map((String value) { + final String label = switch (value) { + 'NO_BREAK' => 'No Break', + 'MIN_10' => '10 min (Paid)', + 'MIN_15' => '15 min (Paid)', + 'MIN_30' => '30 min (Unpaid)', + 'MIN_45' => '45 min (Unpaid)', + 'MIN_60' => '60 min (Unpaid)', + _ => value, + }; + return DropdownMenuItem( + value: value, + child: Text( + label, + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimeInput({ + required BuildContext context, + required String label, + required String value, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (OrderRoleUiModel role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((OrderRoleUiModel role) => role.id == position.role); + if (position.role.isNotEmpty && !hasSelected) { + items.add( + DropdownMenuItem( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart new file mode 100644 index 00000000..21d47825 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for sections in the permanent order form. +class PermanentOrderSectionHeader extends StatelessWidget { + /// Creates a [PermanentOrderSectionHeader]. + const PermanentOrderSectionHeader({ + required this.title, + this.actionLabel, + this.onAction, + super.key, + }); + + /// The title text for the section. + final String title; + + /// Optional label for an action button on the right. + final String? actionLabel; + + /// Callback when the action button is tapped. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.headline4m.textPrimary), + if (actionLabel != null && onAction != null) + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart new file mode 100644 index 00000000..a4b72cbc --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart @@ -0,0 +1,104 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A view to display when a permanent order has been successfully created. +class PermanentOrderSuccessView extends StatelessWidget { + /// Creates a [PermanentOrderSuccessView]. + const PermanentOrderSuccessView({ + required this.title, + required this.message, + required this.buttonLabel, + required this.onDone, + super.key, + }); + + /// The title of the success message. + final String title; + + /// The body of the success message. + final String message; + + /// Label for the completion button. + final String buttonLabel; + + /// Callback when the completion button is tapped. + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + ), + ), + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10), + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg * 1.5, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: UiConstants.space16, + height: UiConstants.space16, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.check, + color: UiColors.black, + size: UiConstants.space8, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + title, + style: UiTypography.headline2m.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space3), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: UiConstants.space8), + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: buttonLabel, + onPressed: onDone, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart new file mode 100644 index 00000000..abcf7a20 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -0,0 +1,495 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; +import 'permanent_order_date_picker.dart'; +import 'permanent_order_event_name_input.dart'; +import 'permanent_order_header.dart'; +import 'permanent_order_position_card.dart'; +import 'permanent_order_section_header.dart'; +import 'permanent_order_success_view.dart'; + +/// The main content of the Permanent Order page. +class PermanentOrderView extends StatelessWidget { + const PermanentOrderView({ + required this.status, + required this.errorMessage, + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.permanentDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.hubManagers, + required this.selectedHubManager, + required this.isValid, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.onSubmit, + required this.onDone, + required this.onBack, + super.key, + }); + + final OrderFormStatus status; + final String? errorMessage; + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime startDate; + final List permanentDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; + final List positions; + final List roles; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onDayToggled; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + final VoidCallback onSubmit; + final VoidCallback onDone; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + if (status == OrderFormStatus.failure && errorMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + UiSnackbar.show( + context, + message: translateErrorKey(errorMessage!), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + }); + } + + if (status == OrderFormStatus.success) { + return PermanentOrderSuccessView( + title: labels.title, + message: labels.subtitle, + buttonLabel: oneTimeLabels.back_to_orders, + onDone: onDone, + ); + } + + if (vendors.isEmpty && status != OrderFormStatus.loading) { + return Scaffold( + body: Column( + children: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _PermanentOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + permanentDays: permanentDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, + ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, + ), + ], + ), + ); + } +} + +class _PermanentOrderForm extends StatelessWidget { + const _PermanentOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.permanentDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + }); + + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime startDate; + final List permanentDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onDayToggled; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderDatePicker( + label: 'Start Date', + value: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _PermanentDaysSelector( + selectedDays: permanentDays, + onToggle: onDayToggled, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + PermanentOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: PermanentOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} + +class _PermanentDaysSelector extends StatelessWidget { + const _PermanentDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart new file mode 100644 index 00000000..f9b7df68 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart @@ -0,0 +1,74 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A date picker field for the recurring order form. +class RecurringOrderDatePicker extends StatefulWidget { + /// Creates a [RecurringOrderDatePicker]. + const RecurringOrderDatePicker({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The label text to display above the field. + final String label; + + /// The currently selected date. + final DateTime value; + + /// Callback when a new date is selected. + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderDatePickerState(); +} + +class _RecurringOrderDatePickerState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(widget.value), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(RecurringOrderDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _controller.text = DateFormat('yyyy-MM-dd').format(widget.value); + } + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + readOnly: true, + prefixIcon: UiIcons.calendar, + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: widget.value, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + widget.onChanged(picked); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart new file mode 100644 index 00000000..22d7cae9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A text input for the order name in the recurring order form. +class RecurringOrderEventNameInput extends StatefulWidget { + const RecurringOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderEventNameInputState(); +} + +class _RecurringOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(RecurringOrderEventNameInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + onChanged: widget.onChanged, + hintText: 'Order name', + prefixIcon: UiIcons.briefcase, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart new file mode 100644 index 00000000..5913b205 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the recurring order flow with a colored background. +class RecurringOrderHeader extends StatelessWidget { + /// Creates a [RecurringOrderHeader]. + const RecurringOrderHeader({ + required this.title, + required this.subtitle, + required this.onBack, + super.key, + }); + + /// The title of the page. + final String title; + + /// The subtitle or description. + final String subtitle; + + /// Callback when the back button is pressed. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + UiConstants.space5, + bottom: UiConstants.space5, + left: UiConstants.space5, + right: UiConstants.space5, + ), + color: UiColors.primary, + child: Row( + children: [ + GestureDetector( + onTap: onBack, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: const Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart new file mode 100644 index 00000000..d6c038af --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart @@ -0,0 +1,321 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../order_ui_models.dart'; + +/// A card widget for editing a specific position in a recurring order. +class RecurringOrderPositionCard extends StatelessWidget { + const RecurringOrderPositionCard({ + required this.index, + required this.position, + required this.isRemovable, + required this.onUpdated, + required this.onRemoved, + required this.positionLabel, + required this.roleLabel, + required this.workersLabel, + required this.startLabel, + required this.endLabel, + required this.lunchLabel, + required this.roles, + super.key, + }); + + final int index; + final OrderPositionUiModel position; + final bool isRemovable; + final ValueChanged onUpdated; + final VoidCallback onRemoved; + final String positionLabel; + final String roleLabel; + final String workersLabel; + final String startLabel; + final String endLabel; + final String lunchLabel; + final List roles; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$positionLabel #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (isRemovable) + GestureDetector( + onTap: onRemoved, + child: Text( + t.client_create_order.one_time.remove, + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Role (Dropdown) + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), + value: position.role.isEmpty ? null : position.role, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(role: val)); + } + }, + items: _buildRoleItems(), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + + // Start/End/Workers Row + Row( + children: [ + // Start Time + Expanded( + child: _buildTimeInput( + context: context, + label: startLabel, + value: position.startTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(startTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // End Time + Expanded( + child: _buildTimeInput( + context: context, + label: endLabel, + value: position.endTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(endTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // Workers Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workersLabel, + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Container( + height: 40, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${position.count}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Lunch Break + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: position.lunchBreak, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(lunchBreak: val)); + } + }, + items: [ + 'NO_BREAK', + 'MIN_10', + 'MIN_15', + 'MIN_30', + 'MIN_45', + 'MIN_60', + ].map((String value) { + final String label = switch (value) { + 'NO_BREAK' => 'No Break', + 'MIN_10' => '10 min (Paid)', + 'MIN_15' => '15 min (Paid)', + 'MIN_30' => '30 min (Unpaid)', + 'MIN_45' => '45 min (Unpaid)', + 'MIN_60' => '60 min (Unpaid)', + _ => value, + }; + return DropdownMenuItem( + value: value, + child: Text( + label, + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimeInput({ + required BuildContext context, + required String label, + required String value, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (OrderRoleUiModel role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((OrderRoleUiModel role) => role.id == position.role); + if (position.role.isNotEmpty && !hasSelected) { + items.add( + DropdownMenuItem( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart new file mode 100644 index 00000000..85326cb6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for sections in the recurring order form. +class RecurringOrderSectionHeader extends StatelessWidget { + /// Creates a [RecurringOrderSectionHeader]. + const RecurringOrderSectionHeader({ + required this.title, + this.actionLabel, + this.onAction, + super.key, + }); + + /// The title text for the section. + final String title; + + /// Optional label for an action button on the right. + final String? actionLabel; + + /// Callback when the action button is tapped. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.headline4m.textPrimary), + if (actionLabel != null && onAction != null) + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart new file mode 100644 index 00000000..3739c5ad --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart @@ -0,0 +1,104 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A view to display when a recurring order has been successfully created. +class RecurringOrderSuccessView extends StatelessWidget { + /// Creates a [RecurringOrderSuccessView]. + const RecurringOrderSuccessView({ + required this.title, + required this.message, + required this.buttonLabel, + required this.onDone, + super.key, + }); + + /// The title of the success message. + final String title; + + /// The body of the success message. + final String message; + + /// Label for the completion button. + final String buttonLabel; + + /// Callback when the completion button is tapped. + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + ), + ), + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10), + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg * 1.5, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: UiConstants.space16, + height: UiConstants.space16, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.check, + color: UiColors.black, + size: UiConstants.space8, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + title, + style: UiTypography.headline2m.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space3), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: UiConstants.space8), + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: buttonLabel, + onPressed: onDone, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart new file mode 100644 index 00000000..fbc00c07 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -0,0 +1,516 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; +import 'recurring_order_date_picker.dart'; +import 'recurring_order_event_name_input.dart'; +import 'recurring_order_header.dart'; +import 'recurring_order_position_card.dart'; +import 'recurring_order_section_header.dart'; +import 'recurring_order_success_view.dart'; + +/// The main content of the Recurring Order page. +class RecurringOrderView extends StatelessWidget { + const RecurringOrderView({ + required this.status, + required this.errorMessage, + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.hubManagers, + required this.selectedHubManager, + required this.isValid, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.onSubmit, + required this.onDone, + required this.onBack, + super.key, + }); + + final OrderFormStatus status; + final String? errorMessage; + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; + final List positions; + final List roles; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onEndDateChanged; + final ValueChanged onDayToggled; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + final VoidCallback onSubmit; + final VoidCallback onDone; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + if (status == OrderFormStatus.failure && errorMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final String message = errorMessage == 'placeholder' + ? labels.placeholder + : translateErrorKey(errorMessage!); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + }); + } + + if (status == OrderFormStatus.success) { + return RecurringOrderSuccessView( + title: labels.title, + message: labels.subtitle, + buttonLabel: oneTimeLabels.back_to_orders, + onDone: onDone, + ); + } + + if (vendors.isEmpty && status != OrderFormStatus.loading) { + return Scaffold( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _RecurringOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + endDate: endDate, + recurringDays: recurringDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onEndDateChanged: onEndDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, + ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, + ), + ], + ), + ); + } +} + +class _RecurringOrderForm extends StatelessWidget { + const _RecurringOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + }); + + final String eventName; + final Vendor? selectedVendor; + final List vendors; + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onEndDateChanged; + final ValueChanged onDayToggled; + final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; + final VoidCallback onPositionAdded; + final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index) onPositionRemoved; + + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'Start Date', + value: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'End Date', + value: endDate, + onChanged: onEndDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _RecurringDaysSelector( + selectedDays: recurringDays, + onToggle: onDayToggled, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + RecurringOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: RecurringOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} + +class _RecurringDaysSelector extends StatelessWidget { + const _RecurringDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/linux/CMakeLists.txt new file mode 100644 index 00000000..baa70a9b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "orders") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.orders") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..64a0ecea --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.h b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2db3c22a --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/runner/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/runner/main.cc b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.cc b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.cc new file mode 100644 index 00000000..a7314d70 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "orders"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "orders"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.h b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.h new file mode 100644 index 00000000..db16367a --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Debug.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..4b81f9b2 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Release.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..5caa9d15 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..8a0af98d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,22 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import file_selector_macos +import firebase_app_check +import firebase_auth +import firebase_core +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Podfile b/apps/mobile/packages/features/client/orders/orders_common/macos/Podfile new file mode 100644 index 00000000..ff5ddb3b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.pbxproj b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..f4cee16f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* orders.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "orders.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* orders.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* orders.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/orders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/orders"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/orders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/orders"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.orders.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/orders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/orders"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..b4e4c542 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/AppDelegate.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Base.lproj/MainMenu.xib b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/AppInfo.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..816c7290 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = orders + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.orders + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright ยฉ 2026 com.example. All rights reserved. diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Debug.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Release.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Warnings.xcconfig b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/DebugProfile.entitlements b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Info.plist b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/MainFlutterWindow.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Release.entitlements b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/RunnerTests/RunnerTests.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml b/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml new file mode 100644 index 00000000..1f17a970 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml @@ -0,0 +1,38 @@ +name: client_orders_common +description: Orders management feature for the client application. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + + # Architecture Packages + design_system: + path: ../../../../design_system + core_localization: + path: ../../../../core_localization + krow_domain: ^0.0.1 + krow_data_connect: ^0.0.1 + krow_core: + path: ../../../../core + + firebase_data_connect: any + intl: any + +dev_dependencies: + flutter_test: + sdk: flutter + bloc_test: ^9.1.0 + mocktail: ^1.0.0 + +flutter: + uses-material-design: true diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/favicon.png b/apps/mobile/packages/features/client/orders/orders_common/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/web/favicon.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-192.png b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-192.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-512.png b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-512.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-192.png b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-192.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-512.png b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/web/icons/Icon-maskable-512.png differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/index.html b/apps/mobile/packages/features/client/orders/orders_common/web/index.html new file mode 100644 index 00000000..06ee7e4a --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + orders + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/web/manifest.json b/apps/mobile/packages/features/client/orders/orders_common/web/manifest.json new file mode 100644 index 00000000..4c83e171 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "orders", + "short_name": "orders", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/.gitignore b/apps/mobile/packages/features/client/orders/orders_common/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/windows/CMakeLists.txt new file mode 100644 index 00000000..25685493 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(orders LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "orders") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..5861e0f0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseAuthPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.h b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..ce851e9d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + firebase_auth + firebase_core +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/CMakeLists.txt b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/Runner.rc b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/Runner.rc new file mode 100644 index 00000000..e5ad78c0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "orders" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "orders" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "orders.exe" "\0" + VALUE "ProductName", "orders" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.cpp b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.h b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/main.cpp b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/main.cpp new file mode 100644 index 00000000..806da7f4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"orders", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resource.h b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resources/app_icon.ico b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..c04e20ca Binary files /dev/null and b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/resources/app_icon.ico differ diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/runner.exe.manifest b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.cpp b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.h b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.cpp b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.h b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart similarity index 70% rename from apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 2886c335..e0e79a28 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; @@ -6,12 +7,10 @@ import '../../domain/repositories/i_view_orders_repository.dart'; /// Implementation of [IViewOrdersRepository] using Data Connect. class ViewOrdersRepositoryImpl implements IViewOrdersRepository { + ViewOrdersRepositoryImpl({required dc.DataConnectService service}) + : _service = service; final dc.DataConnectService _service; - ViewOrdersRepositoryImpl({ - required dc.DataConnectService service, - }) : _service = service; - @override Future> getOrdersForRange({ required DateTime start, @@ -20,49 +19,68 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { return _service.run(() async { final String businessId = await _service.getBusinessId(); - final fdc.Timestamp startTimestamp = _service.toTimestamp(_startOfDay(start)); + final fdc.Timestamp startTimestamp = _service.toTimestamp( + _startOfDay(start), + ); final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end)); - final fdc.QueryResult result = - await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute(); - print( + final fdc.QueryResult< + dc.ListShiftRolesByBusinessAndDateRangeData, + dc.ListShiftRolesByBusinessAndDateRangeVariables + > + result = await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ) + .execute(); + debugPrint( 'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}', ); final String businessName = - dc.ClientSessionStore.instance.session?.business?.businessName ?? 'Your Company'; + dc.ClientSessionStore.instance.session?.business?.businessName ?? + 'Your Company'; - return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) { - final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal(); - final String dateStr = shiftDate == null ? '' : DateFormat('yyyy-MM-dd').format(shiftDate); + return result.data.shiftRoles.map(( + dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole, + ) { + final DateTime? shiftDate = shiftRole.shift.date + ?.toDateTime() + .toLocal(); + final String dateStr = shiftDate == null + ? '' + : DateFormat('yyyy-MM-dd').format(shiftDate); final String startTime = _formatTime(shiftRole.startTime); final String endTime = _formatTime(shiftRole.endTime); final int filled = shiftRole.assigned ?? 0; final int workersNeeded = shiftRole.count; final double hours = shiftRole.hours ?? 0; final double totalValue = shiftRole.totalValue ?? 0; - final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours); + final double hourlyRate = _hourlyRate( + shiftRole.totalValue, + shiftRole.hours, + ); // final String status = filled >= workersNeeded ? 'filled' : 'open'; final String status = shiftRole.shift.status?.stringValue ?? 'OPEN'; - print( + debugPrint( 'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} ' 'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} ' 'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue', ); - final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title; + final String eventName = + shiftRole.shift.order.eventName ?? shiftRole.shift.title; return domain.OrderItem( id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - $eventName', + orderType: domain.OrderType.fromString( + shiftRole.shift.order.orderType.stringValue, + ), + title: shiftRole.role.name, + eventName: eventName, clientName: businessName, status: status, date: dateStr, @@ -90,28 +108,35 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day)); final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day)); - final fdc.QueryResult result = - await _service.connector - .listAcceptedApplicationsByBusinessForDay( - businessId: businessId, - dayStart: dayStart, - dayEnd: dayEnd, - ) - .execute(); + final fdc.QueryResult< + dc.ListAcceptedApplicationsByBusinessForDayData, + dc.ListAcceptedApplicationsByBusinessForDayVariables + > + result = await _service.connector + .listAcceptedApplicationsByBusinessForDay( + businessId: businessId, + dayStart: dayStart, + dayEnd: dayEnd, + ) + .execute(); print( 'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}', ); - final Map>> grouped = >>{}; - for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application + final Map>> grouped = + >>{}; + for (final dc.ListAcceptedApplicationsByBusinessForDayApplications + application in result.data.applications) { print( 'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} ' 'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}', ); - final String key = _shiftRoleKey(application.shiftId, application.roleId); + final String key = _shiftRoleKey( + application.shiftId, + application.roleId, + ); grouped.putIfAbsent(key, () => >[]); grouped[key]!.add({ 'id': application.id, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/arguments/orders_day_arguments.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/domain/arguments/orders_day_arguments.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/arguments/orders_range_arguments.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_range_arguments.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/domain/arguments/orders_range_arguments.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_range_arguments.dart diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart index 8eb17cca..e8e9152f 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart @@ -9,10 +9,10 @@ import '../arguments/orders_range_arguments.dart'; /// and delegates the data retrieval to the [IViewOrdersRepository]. class GetOrdersUseCase implements UseCase> { - final IViewOrdersRepository _repository; /// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository]. GetOrdersUseCase(this._repository); + final IViewOrdersRepository _repository; @override Future> call(OrdersRangeArguments input) { diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart similarity index 87% rename from apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart index 81c3ba32..3ea97a5e 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart @@ -85,11 +85,9 @@ class ViewOrdersCubit extends Cubit final DateTime? selectedDate = state.selectedDate; final DateTime updatedSelectedDate = selectedDate != null && - calendarDays.any( - (DateTime day) => _isSameDay(day, selectedDate), - ) - ? selectedDate - : calendarDays.first; + calendarDays.any((DateTime day) => _isSameDay(day, selectedDate)) + ? selectedDate + : calendarDays.first; emit( state.copyWith( weekOffset: newWeekOffset, @@ -173,12 +171,15 @@ class ViewOrdersCubit extends Cubit } final int filled = confirmed.length; - final String status = - filled >= order.workersNeeded ? 'FILLED' : order.status; + final String status = filled >= order.workersNeeded + ? 'FILLED' + : order.status; return OrderItem( id: order.id, orderId: order.orderId, + orderType: order.orderType, title: order.title, + eventName: order.eventName, clientName: order.clientName, status: status, date: order.date, @@ -223,10 +224,9 @@ class ViewOrdersCubit extends Cubit ).format(state.selectedDate!); // Filter by date - final List ordersOnDate = - state.orders - .where((OrderItem s) => s.date == selectedDateStr) - .toList(); + final List ordersOnDate = state.orders + .where((OrderItem s) => s.date == selectedDateStr) + .toList(); // Sort by start time ordersOnDate.sort( @@ -234,38 +234,35 @@ class ViewOrdersCubit extends Cubit ); if (state.filterTab == 'all') { - final List filtered = - ordersOnDate - .where( - (OrderItem s) => - // TODO(orders): move PENDING to its own tab once available. - [ - 'OPEN', - 'FILLED', - 'CONFIRMED', - 'PENDING', - 'ASSIGNED', - ].contains(s.status), - ) - .toList(); + final List filtered = ordersOnDate + .where( + (OrderItem s) => + // TODO(orders): move PENDING to its own tab once available. + [ + 'OPEN', + 'FILLED', + 'CONFIRMED', + 'PENDING', + 'ASSIGNED', + ].contains(s.status), + ) + .toList(); print( 'ViewOrders tab=all statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', ); return filtered; } else if (state.filterTab == 'active') { - final List filtered = - ordersOnDate - .where((OrderItem s) => s.status == 'IN_PROGRESS') - .toList(); + final List filtered = ordersOnDate + .where((OrderItem s) => s.status == 'IN_PROGRESS') + .toList(); print( 'ViewOrders tab=active statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', ); return filtered; } else if (state.filterTab == 'completed') { - final List filtered = - ordersOnDate - .where((OrderItem s) => s.status == 'COMPLETED') - .toList(); + final List filtered = ordersOnDate + .where((OrderItem s) => s.status == 'COMPLETED') + .toList(); print( 'ViewOrders tab=completed statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', ); @@ -321,4 +318,3 @@ class ViewOrdersCubit extends Cubit .length; } } - diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_state.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_state.dart diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart new file mode 100644 index 00000000..6c0a8923 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -0,0 +1,126 @@ +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:core_localization/core_localization.dart'; +import '../blocs/view_orders_cubit.dart'; +import '../blocs/view_orders_state.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../widgets/view_orders_header.dart'; +import '../widgets/view_orders_empty_state.dart'; +import '../widgets/view_orders_error_state.dart'; +import '../widgets/view_orders_list.dart'; + +/// The main page for viewing client orders. +/// +/// This page follows the KROW Clean Architecture by: +/// - Being a [StatelessWidget]. +/// - Using [ViewOrdersCubit] for state management. +/// - Adhering to the project's Design System. +class ViewOrdersPage extends StatelessWidget { + /// Creates a [ViewOrdersPage]. + const ViewOrdersPage({super.key, this.initialDate}); + + /// The initial date to display orders for. + final DateTime? initialDate; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: Modular.get(), + child: ViewOrdersView(initialDate: initialDate), + ); + } +} + +/// The internal view implementation for [ViewOrdersPage]. +class ViewOrdersView extends StatefulWidget { + /// Creates a [ViewOrdersView]. + const ViewOrdersView({super.key, this.initialDate}); + + /// The initial date to display orders for. + final DateTime? initialDate; + + @override + State createState() => _ViewOrdersViewState(); +} + +class _ViewOrdersViewState extends State { + bool _didInitialJump = false; + ViewOrdersCubit? _cubit; + + @override + void initState() { + super.initState(); + // Force initialization of cubit immediately + _cubit = BlocProvider.of(context, listen: false); + + if (widget.initialDate != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_didInitialJump) return; + _didInitialJump = true; + _cubit?.jumpToDate(widget.initialDate!); + }); + } + } + + @override + void didUpdateWidget(ViewOrdersView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialDate != oldWidget.initialDate && + widget.initialDate != null) { + _cubit?.jumpToDate(widget.initialDate!); + } + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (BuildContext context, ViewOrdersState state) { + if (state.status == ViewOrdersStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ViewOrdersState state) { + final List calendarDays = state.calendarDays; + final List filteredOrders = state.filteredOrders; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Header + Filter + Calendar (Sticky behavior) + ViewOrdersHeader(state: state, calendarDays: calendarDays), + + // Content List + Expanded( + child: state.status == ViewOrdersStatus.failure + ? ViewOrdersErrorState( + errorMessage: state.errorMessage, + selectedDate: state.selectedDate, + onRetry: () => BlocProvider.of( + context, + ).jumpToDate(state.selectedDate ?? DateTime.now()), + ) + : filteredOrders.isEmpty + ? ViewOrdersEmptyState(selectedDate: state.selectedDate) + : ViewOrdersList( + orders: filteredOrders, + filterTab: state.filterTab, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart similarity index 61% rename from apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index 0f875aff..a8cd6843 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -3,689 +3,9 @@ import 'package:design_system/design_system.dart'; import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../blocs/view_orders_cubit.dart'; - -/// A rich card displaying details of a client order/shift. -/// -/// This widget complies with the KROW Design System by using -/// tokens from `package:design_system`. -class ViewOrderCard extends StatefulWidget { - /// Creates a [ViewOrderCard] for the given [order]. - const ViewOrderCard({required this.order, super.key}); - - /// The order item to display. - final OrderItem order; - - @override - State createState() => _ViewOrderCardState(); -} - -class _ViewOrderCardState extends State { - bool _expanded = true; - - void _openEditSheet({required OrderItem order}) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: UiColors.transparent, - builder: (BuildContext context) => _OrderEditSheet( - order: order, - onUpdated: () => this.context.read().updateWeekOffset(0), - ), - ); - } - - /// Returns the semantic color for the given status. - Color _getStatusColor({required String status}) { - switch (status) { - case 'OPEN': - return UiColors.primary; - case 'FILLED': - case 'CONFIRMED': - return UiColors.textSuccess; - case 'IN_PROGRESS': - return UiColors.textWarning; - case 'COMPLETED': - return UiColors.primary; - case 'CANCELED': - return UiColors.destructive; - default: - return UiColors.textSecondary; - } - } - - /// Returns the localized label for the given status. - String _getStatusLabel({required String status}) { - switch (status) { - case 'OPEN': - return t.client_view_orders.card.open; - case 'FILLED': - return t.client_view_orders.card.filled; - case 'CONFIRMED': - return t.client_view_orders.card.confirmed; - case 'IN_PROGRESS': - return t.client_view_orders.card.in_progress; - case 'COMPLETED': - return t.client_view_orders.card.completed; - case 'CANCELED': - return t.client_view_orders.card.cancelled; - default: - return status.toUpperCase(); - } - } - - /// Formats the time string for display. - String _formatTime({required String timeStr}) { - if (timeStr.isEmpty) return ''; - try { - final List parts = timeStr.split(':'); - int hour = int.parse(parts[0]); - final int minute = int.parse(parts[1]); - final String ampm = hour >= 12 ? 'PM' : 'AM'; - hour = hour % 12; - if (hour == 0) hour = 12; - return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; - } catch (_) { - return timeStr; - } - } - - @override - Widget build(BuildContext context) { - final OrderItem order = widget.order; - final Color statusColor = _getStatusColor(status: order.status); - final String statusLabel = _getStatusLabel(status: order.status); - final int coveragePercent = order.workersNeeded > 0 - ? ((order.filled / order.workersNeeded) * 100).round() - : 0; - - final double hours = order.hours; - final double cost = order.totalValue; - - return Container( - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Row - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status Badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusSm, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: statusColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: UiConstants.space1 + 2), - Text( - statusLabel.toUpperCase(), - style: UiTypography.footnote2b.copyWith( - color: statusColor, - letterSpacing: 0.5, - ), - ), - ], - ), - ), - const SizedBox(height: UiConstants.space3), - // Title - Text( - order.title, - style: UiTypography.headline4m.textPrimary, - ), - const SizedBox(height: UiConstants.space1), - // Client & Date - Row( - children: [ - Text( - order.clientName, - style: UiTypography.body3r.textSecondary, - ), - const SizedBox(width: 0), - ], - ), - const SizedBox(height: UiConstants.space2), - // Location (Hub name + Address) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(top: 2), - child: Icon( - UiIcons.mapPin, - size: 14, - color: UiColors.iconSecondary, - ), - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (order.location.isNotEmpty) - Text( - order.location, - style: UiTypography.footnote1b.textPrimary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (order.locationAddress.isNotEmpty) - Text( - order.locationAddress, - style: UiTypography.footnote2r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ], - ), - ), - const SizedBox(width: UiConstants.space3), - // Actions - Row( - children: [ - _buildHeaderIconButton( - icon: UiIcons.edit, - color: UiColors.primary, - bgColor: UiColors.primary.withValues(alpha: 0.08), - onTap: () => _openEditSheet(order: order), - ), - const SizedBox(width: UiConstants.space2), - if (order.confirmedApps.isNotEmpty) - _buildHeaderIconButton( - icon: _expanded - ? UiIcons.chevronUp - : UiIcons.chevronDown, - color: UiColors.iconSecondary, - bgColor: UiColors.bgSecondary, - onTap: () => - setState(() => _expanded = !_expanded), - ), - ], - ), - ], - ), - - const SizedBox(height: UiConstants.space4), - const Divider(height: 1, color: UiColors.border), - const SizedBox(height: UiConstants.space4), - - // Stats Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildStatItem( - icon: UiIcons.dollar, - value: '\$${cost.round()}', - label: t.client_view_orders.card.total, - ), - _buildStatDivider(), - _buildStatItem( - icon: UiIcons.clock, - value: hours.toStringAsFixed(1), - label: t.client_view_orders.card.hrs, - ), - _buildStatDivider(), - _buildStatItem( - icon: UiIcons.users, - value: '${order.workersNeeded}', - label: t.client_create_order.one_time.workers_label, - ), - ], - ), - - const SizedBox(height: UiConstants.space5), - - // Times Section - Row( - children: [ - Expanded( - child: _buildTimeDisplay( - label: t.client_view_orders.card.clock_in, - time: _formatTime(timeStr: order.startTime), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _buildTimeDisplay( - label: t.client_view_orders.card.clock_out, - time: _formatTime(timeStr: order.endTime), - ), - ), - ], - ), - - const SizedBox(height: UiConstants.space4), - - // Coverage Section - if (order.status != 'completed') ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (coveragePercent != 100) - const Icon( - UiIcons.error, - size: 16, - color: UiColors.textError, - ), - if (coveragePercent == 100) - const Icon( - UiIcons.checkCircle, - size: 16, - color: UiColors.textSuccess, - ), - const SizedBox(width: UiConstants.space2), - Text( - coveragePercent == 100 - ? t.client_view_orders.card.all_confirmed - : t.client_view_orders.card.workers_needed(count: order.workersNeeded), - style: UiTypography.body2m.textPrimary, - ), - ], - ), - Text( - '$coveragePercent%', - style: UiTypography.body2b.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space2 + 2), - ClipRRect( - borderRadius: UiConstants.radiusFull, - child: LinearProgressIndicator( - value: coveragePercent / 100, - backgroundColor: UiColors.bgSecondary, - valueColor: const AlwaysStoppedAnimation( - UiColors.primary, - ), - minHeight: 8, - ), - ), - - // Avatar Stack Preview (if not expanded) - if (!_expanded && order.confirmedApps.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space4), - Row( - children: [ - _buildAvatarStack(order.confirmedApps), - if (order.confirmedApps.length > 3) - Padding( - padding: const EdgeInsets.only(left: 12), - child: Text( - t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 3), - style: UiTypography.footnote2r.textSecondary, - ), - ), - ], - ), - ], - ], - ], - ), - ), - - // Assigned Workers (Expanded section) - if (_expanded && order.confirmedApps.isNotEmpty) ...[ - Container( - decoration: const BoxDecoration( - color: UiColors.bgSecondary, - border: Border(top: BorderSide(color: UiColors.border)), - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(UiConstants.radiusBase), - ), - ), - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_view_orders.card.confirmed_workers_title, - style: UiTypography.footnote2b.textSecondary, - ), - GestureDetector( - onTap: () {}, - child: Text( - t.client_view_orders.card.message_all, - style: UiTypography.footnote2b.copyWith( - color: UiColors.primary, - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - ...order.confirmedApps - .take(5) - .map((Map app) => _buildWorkerRow(app)), - if (order.confirmedApps.length > 5) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Center( - child: TextButton( - onPressed: () {}, - child: Text( - t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 5), - style: UiTypography.body2m.copyWith( - color: UiColors.primary, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - Widget _buildStatDivider() { - return Container(width: 1, height: 24, color: UiColors.border); - } - - Widget _buildTimeDisplay({required String label, required String time}) { - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: UiTypography.titleUppercase4m.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Text(time, style: UiTypography.body1b.textPrimary), - ], - ), - ); - } - - /// Builds a stacked avatar UI for a list of applications. - Widget _buildAvatarStack(List> apps) { - const double size = 32.0; - const double overlap = 22.0; - final int count = apps.length > 3 ? 3 : apps.length; - - return SizedBox( - height: size, - width: size + (count - 1) * overlap, - child: Stack( - children: [ - for (int i = 0; i < count; i++) - Positioned( - left: i * overlap, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: UiColors.white, width: 2), - color: UiColors.primary.withValues(alpha: 0.1), - ), - child: Center( - child: Text( - (apps[i]['worker_name'] as String)[0], - style: UiTypography.footnote2b.copyWith( - color: UiColors.primary, - ), - ), - ), - ), - ), - ], - ), - ); - } - - /// Builds a detailed row for a worker. - Widget _buildWorkerRow(Map app) { - final String? phone = app['phone'] as String?; - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - CircleAvatar( - backgroundColor: UiColors.primary.withValues(alpha: 0.1), - child: Text( - (app['worker_name'] as String)[0], - style: UiTypography.body1b.copyWith(color: UiColors.primary), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - app['worker_name'] as String, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space1 / 2), - Row( - children: [ - if ((app['rating'] as num?) != null && - (app['rating'] as num) > 0) ...[ - const Icon( - UiIcons.star, - size: 10, - color: UiColors.accent, - ), - const SizedBox(width: 2), - Text( - (app['rating'] as num).toStringAsFixed(1), - style: UiTypography.footnote2r.textSecondary, - ), - ], - if (app['check_in_time'] != null) ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: UiColors.textSuccess.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusSm, - ), - child: Text( - t.client_view_orders.card.checked_in, - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSuccess, - ), - ), - ), - ] else if ((app['status'] as String?)?.isNotEmpty ?? false) ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - ), - child: Text( - (app['status'] as String).toUpperCase(), - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSecondary, - ), - ), - ), - ], - ], - ), - ], - ), - ), - if (phone != null && phone.isNotEmpty) ...[ - _buildActionIconButton( - icon: UiIcons.phone, - onTap: () => _confirmAndCall(phone), - ), - ], - ], - ), - ); - } - - Future _confirmAndCall(String phone) async { - final bool? shouldCall = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.client_view_orders.card.call_dialog.title), - content: Text(t.client_view_orders.card.call_dialog.message(phone: phone)), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(t.client_view_orders.card.call_dialog.title), - ), - ], - ); - }, - ); - - if (shouldCall != true) { - return; - } - - final Uri uri = Uri(scheme: 'tel', path: phone); - await launchUrl(uri); - } - - /// Specialized action button for worker rows. - Widget _buildActionIconButton({ - required IconData icon, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.circular(UiConstants.space2), - ), - child: Icon(icon, size: 16, color: UiColors.primary), - ), - ); - } - - /// Builds a small icon button used in row headers. - Widget _buildHeaderIconButton({ - required IconData icon, - required Color color, - required Color bgColor, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: bgColor, - borderRadius: UiConstants.radiusSm, - ), - child: Icon(icon, size: 16, color: color), - ), - ); - } - - /// Builds a single stat item (e.g., Cost, Hours, Workers). - Widget _buildStatItem({ - required IconData icon, - required String value, - required String label, - }) { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 14, color: UiColors.iconSecondary), - const SizedBox(width: 6), - Text(value, style: UiTypography.body1b.textPrimary), - ], - ), - const SizedBox(height: 2), - Text( - label.toUpperCase(), - style: UiTypography.titleUppercase4m.textInactive, - ), - ], - ); - } -} class _RoleOption { const _RoleOption({ @@ -708,20 +28,17 @@ class _ShiftRoleKey { /// A sophisticated bottom sheet for editing an existing order, /// following the Unified Order Flow prototype and matching OneTimeOrderView. -class _OrderEditSheet extends StatefulWidget { - const _OrderEditSheet({ - required this.order, - this.onUpdated, - }); +class OrderEditSheet extends StatefulWidget { + const OrderEditSheet({required this.order, this.onUpdated, super.key}); final OrderItem order; final VoidCallback? onUpdated; @override - State<_OrderEditSheet> createState() => _OrderEditSheetState(); + State createState() => OrderEditSheetState(); } -class _OrderEditSheetState extends State<_OrderEditSheet> { +class OrderEditSheetState extends State { bool _showReview = false; bool _isLoading = false; @@ -737,9 +54,13 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { List _vendors = const []; Vendor? _selectedVendor; List<_RoleOption> _roles = const <_RoleOption>[]; - List _hubs = const []; + List _hubs = + const []; dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; + List _managers = const []; + dc.ListTeamMembersTeamMembers? _selectedManager; + String? _shiftId; List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[]; @@ -791,8 +112,10 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { try { final QueryResult< - dc.ListShiftRolesByBusinessAndOrderData, - dc.ListShiftRolesByBusinessAndOrderVariables> result = await _dataConnect + dc.ListShiftRolesByBusinessAndOrderData, + dc.ListShiftRolesByBusinessAndOrderVariables + > + result = await _dataConnect .listShiftRolesByBusinessAndOrder( businessId: businessId, orderId: widget.order.orderId, @@ -819,8 +142,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { _orderNameController.text = firstShift.order.eventName ?? ''; _shiftId = shiftRoles.first.shiftId; - final List> positions = - shiftRoles.map((dc.ListShiftRolesByBusinessAndOrderShiftRoles role) { + final List> positions = shiftRoles.map(( + dc.ListShiftRolesByBusinessAndOrderShiftRoles role, + ) { return { 'shiftId': role.shiftId, 'roleId': role.roleId, @@ -838,21 +162,20 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { positions.add(_emptyPosition()); } - final List<_ShiftRoleKey> originalShiftRoles = - shiftRoles - .map( - (dc.ListShiftRolesByBusinessAndOrderShiftRoles role) => - _ShiftRoleKey(shiftId: role.shiftId, roleId: role.roleId), - ) - .toList(); + final List<_ShiftRoleKey> originalShiftRoles = shiftRoles + .map( + (dc.ListShiftRolesByBusinessAndOrderShiftRoles role) => + _ShiftRoleKey(shiftId: role.shiftId, roleId: role.roleId), + ) + .toList(); await _loadVendorsAndSelect(firstShift.order.vendorId); - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub? + final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub teamHub = firstShift.order.teamHub; await _loadHubsAndSelect( - placeId: teamHub?.placeId, - hubName: teamHub?.hubName, - address: teamHub?.address, + placeId: teamHub.placeId, + hubName: teamHub.hubName, + address: teamHub.address, ); if (mounted) { @@ -879,8 +202,10 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { try { final QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables> result = await _dataConnect + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > + result = await _dataConnect .listTeamHubsByOwnerId(ownerId: businessId) .execute(); @@ -925,6 +250,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } }); } + if (selected != null) { + await _loadManagersForHub(selected.id, widget.order.hubManagerId); + } } catch (_) { if (mounted) { setState(() { @@ -937,8 +265,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { Future _loadVendorsAndSelect(String? selectedVendorId) async { try { - final QueryResult result = - await _dataConnect.listVendors().execute(); + final QueryResult result = await _dataConnect + .listVendors() + .execute(); final List vendors = result.data.vendors .map( (dc.ListVendorsVendors vendor) => Vendor( @@ -983,10 +312,13 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { Future _loadRolesForVendor(String vendorId) async { try { - final QueryResult - result = await _dataConnect - .listRolesByVendorId(vendorId: vendorId) - .execute(); + final QueryResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + result = await _dataConnect + .listRolesByVendorId(vendorId: vendorId) + .execute(); final List<_RoleOption> roles = result.data.roles .map( (dc.ListRolesByVendorIdRoles role) => _RoleOption( @@ -1006,6 +338,47 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } } + Future _loadManagersForHub(String hubId, [String? preselectedId]) async { + try { + final QueryResult result = + await _dataConnect.listTeamMembers().execute(); + + final List hubManagers = result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .toList(); + + dc.ListTeamMembersTeamMembers? selected; + if (preselectedId != null && preselectedId.isNotEmpty) { + for (final dc.ListTeamMembersTeamMembers m in hubManagers) { + if (m.id == preselectedId) { + selected = m; + break; + } + } + } + + if (mounted) { + setState(() { + _managers = hubManagers; + _selectedManager = selected; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _managers = const []; + _selectedManager = null; + }); + } + } + } + Map _emptyPosition() { return { 'shiftId': _shiftId, @@ -1030,8 +403,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } String _breakValueFromDuration(dc.EnumValue? breakType) { - final dc.BreakDuration? value = - breakType is dc.Known ? breakType.value : null; + final dc.BreakDuration? value = breakType is dc.Known + ? breakType.value + : null; switch (value) { case dc.BreakDuration.MIN_10: return 'MIN_10'; @@ -1130,8 +504,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { final DateTime date = _parseDate(_dateController.text); final DateTime start = _parseTime(date, pos['start_time'].toString()); final DateTime end = _parseTime(date, pos['end_time'].toString()); - final DateTime normalizedEnd = - end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; final double hours = normalizedEnd.difference(start).inMinutes / 60.0; final double rate = _rateForRole(roleId); final int count = pos['count'] as int; @@ -1161,8 +536,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { int totalWorkers = 0; double shiftCost = 0; - final List<_ShiftRoleKey> remainingOriginal = - List<_ShiftRoleKey>.from(_originalShiftRoles); + final List<_ShiftRoleKey> remainingOriginal = List<_ShiftRoleKey>.from( + _originalShiftRoles, + ); for (final Map pos in _positions) { final String roleId = pos['roleId']?.toString() ?? ''; @@ -1172,10 +548,14 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { final String shiftId = pos['shiftId']?.toString() ?? _shiftId!; final int count = pos['count'] as int; - final DateTime start = _parseTime(orderDate, pos['start_time'].toString()); + final DateTime start = _parseTime( + orderDate, + pos['start_time'].toString(), + ); final DateTime end = _parseTime(orderDate, pos['end_time'].toString()); - final DateTime normalizedEnd = - end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final DateTime normalizedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; final double hours = normalizedEnd.difference(start).inMinutes / 60.0; final double rate = _rateForRole(roleId); final double totalValue = rate * hours * count; @@ -1196,11 +576,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { .deleteShiftRole(shiftId: shiftId, roleId: originalRoleId) .execute(); await _dataConnect - .createShiftRole( - shiftId: shiftId, - roleId: roleId, - count: count, - ) + .createShiftRole(shiftId: shiftId, roleId: roleId, count: count) .startTime(_toTimestamp(start)) .endTime(_toTimestamp(normalizedEnd)) .hours(hours) @@ -1222,11 +598,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } } else { await _dataConnect - .createShiftRole( - shiftId: shiftId, - roleId: roleId, - count: count, - ) + .createShiftRole(shiftId: shiftId, roleId: roleId, count: count) .startTime(_toTimestamp(start)) .endTime(_toTimestamp(normalizedEnd)) .hours(hours) @@ -1315,7 +687,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { padding: const EdgeInsets.all(UiConstants.space5), children: [ Text( - 'Edit Your Order', + t.client_view_orders.order_edit_sheet.title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space4), @@ -1373,7 +745,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { _buildSectionHeader('ORDER NAME'), UiTextField( controller: _orderNameController, - hintText: 'Order name', + hintText: t.client_view_orders.order_edit_sheet.order_name_hint, prefixIcon: UiIcons.briefcase, ), const SizedBox(height: UiConstants.space4), @@ -1398,8 +770,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { size: 18, color: UiColors.iconSecondary, ), - onChanged: - (dc.ListTeamHubsByOwnerIdTeamHubs? hub) { + onChanged: (dc.ListTeamHubsByOwnerIdTeamHubs? hub) { if (hub != null) { setState(() { _selectedHub = hub; @@ -1407,28 +778,31 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { }); } }, - items: _hubs.map( - (dc.ListTeamHubsByOwnerIdTeamHubs hub) { - return DropdownMenuItem< - dc.ListTeamHubsByOwnerIdTeamHubs>( - value: hub, - child: Text( - hub.hubName, - style: UiTypography.body2m.textPrimary, - ), - ); - }, - ).toList(), + items: _hubs.map((dc.ListTeamHubsByOwnerIdTeamHubs hub) { + return DropdownMenuItem< + dc.ListTeamHubsByOwnerIdTeamHubs + >( + value: hub, + child: Text( + hub.hubName, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), ), ), ), + const SizedBox(height: UiConstants.space4), + + _buildHubManagerSelector(), + const SizedBox(height: UiConstants.space6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'POSITIONS', + t.client_view_orders.order_edit_sheet.positions_section, style: UiTypography.headline4m.textPrimary, ), TextButton( @@ -1442,9 +816,13 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { mainAxisSize: MainAxisSize.min, spacing: UiConstants.space2, children: [ - const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const Icon( + UiIcons.add, + size: 16, + color: UiColors.primary, + ), Text( - 'Add Position', + t.client_view_orders.order_edit_sheet.add_position, style: UiTypography.body2m.primary, ), ], @@ -1465,20 +843,156 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), ), _buildBottomAction( - label: 'Review ${_positions.length} Positions', + label: t.client_view_orders.order_edit_sheet.review_positions(count: _positions.length.toString()), onPressed: () => setState(() => _showReview = true), ), + const Padding( + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + 0, + UiConstants.space5, + 0, + ), + ), ], ), ); } + Widget _buildHubManagerSelector() { + final TranslationsClientViewOrdersOrderEditSheetEn oes = + t.client_view_orders.order_edit_sheet; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(oes.shift_contact_section), + Text(oes.shift_contact_desc, style: UiTypography.body2r.textSecondary), + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: () => _showHubManagerSelector(), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: _selectedManager != null ? UiColors.primary : UiColors.border, + width: _selectedManager != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + UiIcons.user, + color: _selectedManager != null + ? UiColors.primary + : UiColors.iconSecondary, + size: 20, + ), + const SizedBox(width: UiConstants.space3), + Text( + _selectedManager?.user.fullName ?? oes.select_contact, + style: _selectedManager != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + Future _showHubManagerSelector() async { + final dc.ListTeamMembersTeamMembers? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_view_orders.order_edit_sheet.shift_contact_section, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + shrinkWrap: true, + itemCount: _managers.isEmpty ? 2 : _managers.length + 1, + itemBuilder: (BuildContext context, int index) { + if (_managers.isEmpty) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(t.client_view_orders.order_edit_sheet.no_hub_managers), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop(null), + ); + } + + if (index == _managers.length) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop(null), + ); + } + final dc.ListTeamMembersTeamMembers manager = _managers[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + title: Text(manager.user.fullName ?? 'Unknown', style: UiTypography.body1m.textPrimary), + onTap: () => Navigator.of(context).pop(manager), + ); + }, + ), + ), + ), + ); + }, + ); + + if (mounted) { + if (selected == null && _managers.isEmpty) { + // Tapped outside or selected None + setState(() => _selectedManager = null); + } else { + setState(() => _selectedManager = selected); + } + } + } + Widget _buildHeader() { return Container( padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), decoration: const BoxDecoration( color: UiColors.primary, - borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space6), + ), ), child: Row( children: [ @@ -1503,11 +1017,11 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'One-Time Order', + t.client_view_orders.order_edit_sheet.one_time_order_title, style: UiTypography.headline3m.copyWith(color: UiColors.white), ), Text( - 'Refine your staffing needs', + t.client_view_orders.order_edit_sheet.refine_subtitle, style: UiTypography.footnote2r.copyWith( color: UiColors.white.withValues(alpha: 0.8), ), @@ -1549,7 +1063,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { GestureDetector( onTap: () => _removePosition(index), child: Text( - 'Remove', + t.client_view_orders.order_edit_sheet.remove, style: UiTypography.footnote1m.copyWith( color: UiColors.destructive, ), @@ -1560,7 +1074,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(height: UiConstants.space3), _buildDropdownField( - hint: 'Select role', + hint: t.client_view_orders.order_edit_sheet.select_role_hint, value: pos['roleId'], items: [ ..._roles.map((_RoleOption role) => role.id), @@ -1595,14 +1109,14 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { children: [ Expanded( child: _buildInlineTimeInput( - label: 'Start', + label: t.client_view_orders.order_edit_sheet.start_label, value: pos['start_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'start_time', @@ -1615,14 +1129,14 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(width: UiConstants.space2), Expanded( child: _buildInlineTimeInput( - label: 'End', + label: t.client_view_orders.order_edit_sheet.end_label, value: pos['end_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'end_time', @@ -1638,7 +1152,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Workers', + t.client_view_orders.order_edit_sheet.workers_label, style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space1), @@ -1693,7 +1207,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), const SizedBox(width: UiConstants.space1), Text( - 'Use different location for this position', + t.client_view_orders.order_edit_sheet.different_location, style: UiTypography.footnote1m.copyWith( color: UiColors.primary, ), @@ -1717,7 +1231,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), const SizedBox(width: UiConstants.space1), Text( - 'Different Location', + t.client_view_orders.order_edit_sheet.different_location_title, style: UiTypography.footnote1m.textSecondary, ), ], @@ -1735,7 +1249,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(height: UiConstants.space2), UiTextField( controller: TextEditingController(text: pos['location']), - hintText: 'Enter different address', + hintText: t.client_view_orders.order_edit_sheet.enter_address_hint, onChanged: (String val) => _updatePosition(index, 'location', val), ), @@ -1746,7 +1260,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { _buildSectionHeader('LUNCH BREAK'), _buildDropdownField( - hint: 'No Break', + hint: t.client_view_orders.order_edit_sheet.no_break, value: pos['lunch_break'], items: [ 'NO_BREAK', @@ -1769,7 +1283,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { case 'MIN_60': return '60 min (Unpaid)'; default: - return 'No Break'; + return t.client_view_orders.order_edit_sheet.no_break; } }, onChanged: (dynamic val) => @@ -1894,7 +1408,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { height: MediaQuery.of(context).size.height * 0.95, decoration: const BoxDecoration( color: UiColors.bgSecondary, - borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space6), + ), ), child: Column( children: [ @@ -1925,11 +1441,11 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSummaryItem('${_positions.length}', 'Positions'), - _buildSummaryItem('$totalWorkers', 'Workers'), + _buildSummaryItem('${_positions.length}', t.client_view_orders.order_edit_sheet.positions), + _buildSummaryItem('$totalWorkers', t.client_view_orders.order_edit_sheet.workers), _buildSummaryItem( '\$${totalCost.round()}', - 'Est. Cost', + t.client_view_orders.order_edit_sheet.est_cost, ), ], ), @@ -1988,7 +1504,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(height: 24), Text( - 'Positions Breakdown', + t.client_view_orders.order_edit_sheet.positions_breakdown, style: UiTypography.body2b.textPrimary, ), const SizedBox(height: 12), @@ -2019,14 +1535,14 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { children: [ Expanded( child: UiButton.secondary( - text: 'Edit', + text: t.client_view_orders.order_edit_sheet.edit_button, onPressed: () => setState(() => _showReview = false), ), ), const SizedBox(width: 12), Expanded( child: UiButton.primary( - text: 'Confirm & Save', + text: t.client_view_orders.order_edit_sheet.confirm_save, onPressed: () async { setState(() => _isLoading = true); await _saveOrderChanges(); @@ -2088,7 +1604,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { children: [ Text( (role?.name ?? pos['roleName']?.toString() ?? '').isEmpty - ? 'Position' + ? t.client_view_orders.order_edit_sheet.position_singular : (role?.name ?? pos['roleName']?.toString() ?? ''), style: UiTypography.body2b.textPrimary, ), @@ -2130,7 +1646,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { height: MediaQuery.of(context).size.height * 0.95, decoration: const BoxDecoration( color: UiColors.primary, - borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space6), + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -2152,14 +1670,14 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), const SizedBox(height: 24), Text( - 'Order Updated!', + t.client_view_orders.order_edit_sheet.order_updated_title, style: UiTypography.headline1m.copyWith(color: UiColors.white), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( - 'Your shift has been updated successfully.', + t.client_view_orders.order_edit_sheet.order_updated_message, textAlign: TextAlign.center, style: UiTypography.body1r.copyWith( color: UiColors.white.withValues(alpha: 0.7), @@ -2170,7 +1688,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: UiButton.secondary( - text: 'Back to Orders', + text: t.client_view_orders.order_edit_sheet.back_to_orders, fullWidth: true, style: OutlinedButton.styleFrom( backgroundColor: UiColors.white, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart new file mode 100644 index 00000000..b5f02c97 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -0,0 +1,762 @@ +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:krow_domain/krow_domain.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../blocs/view_orders_cubit.dart'; + +/// A rich card displaying details of a client order/shift. +/// +/// This widget complies with the KROW Design System by using +/// tokens from `package:design_system`. +import 'order_edit_sheet.dart'; + +class ViewOrderCard extends StatefulWidget { + /// Creates a [ViewOrderCard] for the given [order]. + const ViewOrderCard({required this.order, super.key}); + + /// The order item to display. + final OrderItem order; + + @override + State createState() => _ViewOrderCardState(); +} + +class _ViewOrderCardState extends State { + bool _expanded = true; + + void _openEditSheet({required OrderItem order}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: UiColors.transparent, + builder: (BuildContext context) => OrderEditSheet( + order: order, + onUpdated: () => + ReadContext(context).read().updateWeekOffset(0), + ), + ); + } + + /// Returns the semantic color for the given status. + Color _getStatusColor({required String status}) { + switch (status) { + case 'OPEN': + return UiColors.primary; + case 'FILLED': + case 'CONFIRMED': + return UiColors.textSuccess; + case 'IN_PROGRESS': + return UiColors.textWarning; + case 'COMPLETED': + return UiColors.primary; + case 'CANCELED': + return UiColors.destructive; + default: + return UiColors.textSecondary; + } + } + + /// Returns the localized label for the given status. + String _getStatusLabel({required String status}) { + switch (status) { + case 'OPEN': + return t.client_view_orders.card.open; + case 'FILLED': + return t.client_view_orders.card.filled; + case 'CONFIRMED': + return t.client_view_orders.card.confirmed; + case 'IN_PROGRESS': + return t.client_view_orders.card.in_progress; + case 'COMPLETED': + return t.client_view_orders.card.completed; + case 'CANCELED': + return t.client_view_orders.card.cancelled; + default: + return status.toUpperCase(); + } + } + + /// Formats the time string for display. + String _formatTime({required String timeStr}) { + if (timeStr.isEmpty) return ''; + try { + final List parts = timeStr.split(':'); + int hour = int.parse(parts[0]); + final int minute = int.parse(parts[1]); + final String ampm = hour >= 12 ? 'PM' : 'AM'; + hour = hour % 12; + if (hour == 0) hour = 12; + return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; + } catch (_) { + return timeStr; + } + } + + String _getOrderTypeLabel(OrderType type) { + switch (type) { + case OrderType.oneTime: + return 'ONE-TIME'; + case OrderType.permanent: + return 'PERMANENT'; + case OrderType.recurring: + return 'RECURRING'; + case OrderType.rapid: + return 'RAPID'; + } + } + + @override + Widget build(BuildContext context) { + final OrderItem order = widget.order; + final Color statusColor = _getStatusColor(status: order.status); + final String statusLabel = _getStatusLabel(status: order.status); + final int coveragePercent = order.workersNeeded > 0 + ? ((order.filled / order.workersNeeded) * 100).round() + : 0; + + final double hours = order.hours; + final double cost = order.totalValue; + + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status and Type Badges + Wrap( + spacing: UiConstants.space2, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox( + width: UiConstants.space1 + 2, + ), + Text( + statusLabel.toUpperCase(), + style: UiTypography.footnote2b.copyWith( + color: statusColor, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Text( + _getOrderTypeLabel(order.orderType), + style: UiTypography.footnote2b.textSecondary, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + // Title + Text(order.title, style: UiTypography.headline3b), + Row( + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.calendarCheck, + size: 14, + color: UiColors.iconSecondary, + ), + Text( + order.eventName, + style: UiTypography.headline5m.textSecondary, + ), + ], + ), + const SizedBox(height: UiConstants.space4), + // Location (Hub name + Address) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon( + UiIcons.mapPin, + size: 14, + color: UiColors.iconSecondary, + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (order.location.isNotEmpty) + Text( + order.location, + style: UiTypography + .footnote1b + .textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (order.locationAddress.isNotEmpty) + Text( + order.locationAddress, + style: UiTypography + .footnote2r + .textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + if (order.hubManagerName != null) ...[ + const SizedBox(height: UiConstants.space2), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon( + UiIcons.user, + size: 14, + color: UiColors.iconSecondary, + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + order.hubManagerName!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: UiConstants.space3), + // Actions + Row( + children: [ + _buildHeaderIconButton( + icon: UiIcons.edit, + color: UiColors.primary, + bgColor: UiColors.primary.withValues(alpha: 0.08), + onTap: () => _openEditSheet(order: order), + ), + const SizedBox(width: UiConstants.space2), + if (order.confirmedApps.isNotEmpty) + _buildHeaderIconButton( + icon: _expanded + ? UiIcons.chevronUp + : UiIcons.chevronDown, + color: UiColors.iconSecondary, + bgColor: UiColors.bgSecondary, + onTap: () => setState(() => _expanded = !_expanded), + ), + ], + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space4), + + // Stats Row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildStatItem( + icon: UiIcons.dollar, + value: '\$${cost.round()}', + label: t.client_view_orders.card.total, + ), + _buildStatDivider(), + _buildStatItem( + icon: UiIcons.clock, + value: hours.toStringAsFixed(1), + label: t.client_view_orders.card.hrs, + ), + _buildStatDivider(), + _buildStatItem( + icon: UiIcons.users, + value: '${order.workersNeeded}', + label: t.client_create_order.one_time.workers_label, + ), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // Times Section + Row( + children: [ + Expanded( + child: _buildTimeDisplay( + label: t.client_view_orders.card.clock_in, + time: _formatTime(timeStr: order.startTime), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeDisplay( + label: t.client_view_orders.card.clock_out, + time: _formatTime(timeStr: order.endTime), + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Coverage Section + if (order.status != 'completed') ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (coveragePercent != 100) + const Icon( + UiIcons.error, + size: 16, + color: UiColors.textError, + ), + if (coveragePercent == 100) + const Icon( + UiIcons.checkCircle, + size: 16, + color: UiColors.textSuccess, + ), + const SizedBox(width: UiConstants.space2), + Text( + coveragePercent == 100 + ? t.client_view_orders.card.all_confirmed + : t.client_view_orders.card.workers_needed( + count: order.workersNeeded, + ), + style: UiTypography.body2m.textPrimary, + ), + ], + ), + Text( + '$coveragePercent%', + style: UiTypography.body2b.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space2 + 2), + ClipRRect( + borderRadius: UiConstants.radiusFull, + child: LinearProgressIndicator( + value: coveragePercent / 100, + backgroundColor: UiColors.bgSecondary, + valueColor: const AlwaysStoppedAnimation( + UiColors.primary, + ), + minHeight: 8, + ), + ), + + // Avatar Stack Preview (if not expanded) + if (!_expanded && order.confirmedApps.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space4), + Row( + children: [ + _buildAvatarStack(order.confirmedApps), + if (order.confirmedApps.length > 3) + Padding( + padding: const EdgeInsets.only(left: 12), + child: Text( + t.client_view_orders.card.show_more_workers( + count: order.confirmedApps.length - 3, + ), + style: UiTypography.footnote2r.textSecondary, + ), + ), + ], + ), + ], + ], + ], + ), + ), + + // Assigned Workers (Expanded section) + if (_expanded && order.confirmedApps.isNotEmpty) ...[ + Container( + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + border: Border(top: BorderSide(color: UiColors.border)), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.radiusBase), + ), + ), + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_view_orders.card.confirmed_workers_title, + style: UiTypography.footnote2b.textSecondary, + ), + GestureDetector( + onTap: () {}, + child: Text( + t.client_view_orders.card.message_all, + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + ...order.confirmedApps + .take(5) + .map((Map app) => _buildWorkerRow(app)), + if (order.confirmedApps.length > 5) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: TextButton( + onPressed: () {}, + child: Text( + t.client_view_orders.card.show_more_workers( + count: order.confirmedApps.length - 5, + ), + style: UiTypography.body2m.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildStatDivider() { + return Container(width: 1, height: 24, color: UiColors.border); + } + + Widget _buildTimeDisplay({required String label, required String time}) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.textSecondary, + ), + Text(time, style: UiTypography.body1b.textPrimary), + ], + ), + ); + } + + /// Builds a stacked avatar UI for a list of applications. + Widget _buildAvatarStack(List> apps) { + const double size = 32.0; + const double overlap = 22.0; + final int count = apps.length > 3 ? 3 : apps.length; + + return SizedBox( + height: size, + width: size + (count - 1) * overlap, + child: Stack( + children: [ + for (int i = 0; i < count; i++) + Positioned( + left: i * overlap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: UiColors.white, width: 2), + color: UiColors.primary.withValues(alpha: 0.1), + ), + child: Center( + child: Text( + (apps[i]['worker_name'] as String)[0], + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// Builds a detailed row for a worker. + Widget _buildWorkerRow(Map app) { + final String? phone = app['phone'] as String?; + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: UiColors.primary.withValues(alpha: 0.1), + child: Text( + (app['worker_name'] as String)[0], + style: UiTypography.body1b.copyWith(color: UiColors.primary), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app['worker_name'] as String, + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space1 / 2), + Row( + children: [ + if ((app['rating'] as num?) != null && + (app['rating'] as num) > 0) ...[ + const Icon( + UiIcons.star, + size: 10, + color: UiColors.accent, + ), + const SizedBox(width: 2), + Text( + (app['rating'] as num).toStringAsFixed(1), + style: UiTypography.footnote2r.textSecondary, + ), + ], + if (app['check_in_time'] != null) ...[ + const SizedBox(width: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.textSuccess.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + t.client_view_orders.card.checked_in, + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSuccess, + ), + ), + ), + ] else if ((app['status'] as String?)?.isNotEmpty ?? + false) ...[ + const SizedBox(width: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Text( + (app['status'] as String).toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ], + ], + ), + ], + ), + ), + if (phone != null && phone.isNotEmpty) ...[ + _buildActionIconButton( + icon: UiIcons.phone, + onTap: () => _confirmAndCall(phone), + ), + ], + ], + ), + ); + } + + Future _confirmAndCall(String phone) async { + final bool? shouldCall = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(t.client_view_orders.card.call_dialog.title), + content: Text( + t.client_view_orders.card.call_dialog.message(phone: phone), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.client_view_orders.card.call_dialog.title), + ), + ], + ); + }, + ); + + if (shouldCall != true) { + return; + } + + final Uri uri = Uri(scheme: 'tel', path: phone); + await launchUrl(uri); + } + + /// Specialized action button for worker rows. + Widget _buildActionIconButton({ + required IconData icon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.circular(UiConstants.space2), + ), + child: Icon(icon, size: 16, color: UiColors.primary), + ), + ); + } + + /// Builds a small icon button used in row headers. + Widget _buildHeaderIconButton({ + required IconData icon, + required Color color, + required Color bgColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: bgColor, + borderRadius: UiConstants.radiusSm, + ), + child: Icon(icon, size: 16, color: color), + ), + ); + } + + /// Builds a single stat item (e.g., Cost, Hours, Workers). + Widget _buildStatItem({ + required IconData icon, + required String value, + required String label, + }) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: UiColors.iconSecondary), + Text(value, style: UiTypography.body1b.textPrimary), + ], + ), + const SizedBox(height: 2), + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.textInactive, + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart new file mode 100644 index 00000000..24362270 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart @@ -0,0 +1,54 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +import 'package:krow_core/core.dart'; + +/// A widget that displays an empty state when no orders are found for a specific date. +class ViewOrdersEmptyState extends StatelessWidget { + /// Creates a [ViewOrdersEmptyState]. + const ViewOrdersEmptyState({super.key, required this.selectedDate}); + + /// The currently selected date to display in the empty state message. + final DateTime? selectedDate; + + @override + Widget build(BuildContext context) { + final String dateStr = selectedDate != null + ? _formatDateHeader(selectedDate!) + : 'this date'; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.calendar, size: 48, color: UiColors.iconInactive), + const SizedBox(height: UiConstants.space3), + Text( + t.client_view_orders.no_orders(date: dateStr), + style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), + ), + const SizedBox(height: UiConstants.space4), + UiButton.primary( + text: t.client_view_orders.post_order, + leadingIcon: UiIcons.add, + onPressed: () => Modular.to.toCreateOrder(), + ), + ], + ), + ); + } + + static String _formatDateHeader(DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime checkDate = DateTime(date.year, date.month, date.day); + + if (checkDate == today) return 'Today'; + if (checkDate == tomorrow) return 'Tomorrow'; + return DateFormat('EEE, MMM d').format(date); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_error_state.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_error_state.dart new file mode 100644 index 00000000..2ff0af22 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_error_state.dart @@ -0,0 +1,45 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A widget that displays an error state when orders fail to load. +class ViewOrdersErrorState extends StatelessWidget { + /// Creates a [ViewOrdersErrorState]. + const ViewOrdersErrorState({ + super.key, + required this.errorMessage, + required this.selectedDate, + required this.onRetry, + }); + + /// The error message to display. + final String? errorMessage; + + /// The selected date to retry loading for. + final DateTime? selectedDate; + + /// Callback to trigger a retry. + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.error, size: 48, color: UiColors.error), + const SizedBox(height: UiConstants.space4), + Text( + errorMessage != null + ? translateErrorKey(errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space4), + UiButton.secondary(text: 'Retry', onPressed: onRetry), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_header.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_header.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list.dart new file mode 100644 index 00000000..a4a5974b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list.dart @@ -0,0 +1,66 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'view_order_card.dart'; +import 'view_orders_list_section_header.dart'; +import 'package:core_localization/core_localization.dart'; + +/// A widget that displays the list of filtered orders. +class ViewOrdersList extends StatelessWidget { + /// Creates a [ViewOrdersList]. + const ViewOrdersList({ + super.key, + required this.orders, + required this.filterTab, + }); + + /// The list of orders to display. + final List orders; + + /// The currently selected filter tab to determine the section title and dot color. + final String filterTab; + + @override + Widget build(BuildContext context) { + if (orders.isEmpty) { + return const SizedBox.shrink(); + } + + String sectionTitle = ''; + Color dotColor = UiColors.transparent; + + if (filterTab == 'all') { + sectionTitle = t.client_view_orders.tabs.up_next; + dotColor = UiColors.primary; + } else if (filterTab == 'active') { + sectionTitle = t.client_view_orders.tabs.active; + dotColor = UiColors.textWarning; + } else if (filterTab == 'completed') { + sectionTitle = t.client_view_orders.tabs.completed; + dotColor = UiColors.primary; + } + + return ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + 100, + ), + children: [ + ViewOrdersListSectionHeader( + title: sectionTitle, + dotColor: dotColor, + count: orders.length, + ), + ...orders.map( + (OrderItem order) => Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: ViewOrderCard(order: order), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list_section_header.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list_section_header.dart new file mode 100644 index 00000000..775ee6ba --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_list_section_header.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A widget that displays the section header for the orders list. +/// +/// Includes a status indicator dot, the section title, and the count of orders. +class ViewOrdersListSectionHeader extends StatelessWidget { + /// Creates a [ViewOrdersListSectionHeader]. + const ViewOrdersListSectionHeader({ + super.key, + required this.title, + required this.dotColor, + required this.count, + }); + + /// The title of the section (e.g., UP NEXT, ACTIVE, COMPLETED). + final String title; + + /// The color of the status indicator dot. + final Color dotColor; + + /// The number of orders in this section. + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: UiConstants.space2), + Text( + title.toUpperCase(), + style: UiTypography.titleUppercase2m.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(width: UiConstants.space1), + Text( + '($count)', + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart similarity index 77% rename from apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart index b23db650..6ba187d2 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart @@ -27,7 +27,7 @@ class ViewOrdersModule extends Module { i.add(GetAcceptedApplicationsForDayUseCase.new); // BLoCs - i.add(ViewOrdersCubit.new); + i.addSingleton(ViewOrdersCubit.new); } @override @@ -37,9 +37,11 @@ class ViewOrdersModule extends Module { child: (BuildContext context) { final Object? args = Modular.args.data; DateTime? initialDate; + + // Try parsing from args.data first if (args is DateTime) { initialDate = args; - } else if (args is Map) { + } else if (args is Map && args['initialDate'] != null) { final Object? rawDate = args['initialDate']; if (rawDate is DateTime) { initialDate = rawDate; @@ -47,6 +49,15 @@ class ViewOrdersModule extends Module { initialDate = DateTime.tryParse(rawDate); } } + + // Fallback to query params + if (initialDate == null) { + final String? queryDate = Modular.args.queryParams['initialDate']; + if (queryDate != null && queryDate.isNotEmpty) { + initialDate = DateTime.tryParse(queryDate); + } + } + return ViewOrdersPage(initialDate: initialDate); }, ); diff --git a/apps/mobile/packages/features/client/view_orders/lib/view_orders.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/view_orders.dart similarity index 100% rename from apps/mobile/packages/features/client/view_orders/lib/view_orders.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/view_orders.dart diff --git a/apps/mobile/packages/features/client/view_orders/pubspec.yaml b/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml similarity index 79% rename from apps/mobile/packages/features/client/view_orders/pubspec.yaml rename to apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml index 5e0b85d4..0628bce9 100644 --- a/apps/mobile/packages/features/client/view_orders/pubspec.yaml +++ b/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml @@ -18,15 +18,15 @@ dependencies: # Shared packages design_system: - path: ../../../design_system + path: ../../../../design_system core_localization: - path: ../../../core_localization + path: ../../../../core_localization krow_domain: - path: ../../../domain + path: ../../../../domain krow_core: - path: ../../../core + path: ../../../../core krow_data_connect: - path: ../../../data_connect + path: ../../../../data_connect # UI intl: ^0.20.1 url_launcher: ^6.3.1 diff --git a/apps/mobile/packages/features/client/reports/lib/client_reports.dart b/apps/mobile/packages/features/client/reports/lib/client_reports.dart new file mode 100644 index 00000000..c8201546 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/client_reports.dart @@ -0,0 +1,4 @@ +library; + +export 'src/reports_module.dart'; +export 'src/presentation/pages/reports_page.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart new file mode 100644 index 00000000..b7e61451 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -0,0 +1,89 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/reports_repository.dart'; + +/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. +class ReportsRepositoryImpl implements ReportsRepository { + + ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository}) + : _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository(); + final ReportsConnectorRepository _connectorRepository; + + @override + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }) => _connectorRepository.getDailyOpsReport( + businessId: businessId, + date: date, + ); + + @override + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getSpendReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); + + @override + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getCoverageReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); + + @override + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getForecastReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); + + @override + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getPerformanceReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); + + @override + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getNoShowReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); + + @override + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) => _connectorRepository.getReportsSummary( + businessId: businessId, + startDate: startDate, + endDate: endDate, + ); +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart new file mode 100644 index 00000000..36ff5d47 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart @@ -0,0 +1,44 @@ +import 'package:krow_domain/krow_domain.dart'; + +abstract class ReportsRepository { + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }); + + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart new file mode 100644 index 00000000..5722ed44 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart @@ -0,0 +1,34 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/coverage_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'coverage_event.dart'; +import 'coverage_state.dart'; + +class CoverageBloc extends Bloc { + + CoverageBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(CoverageInitial()) { + on(_onLoadCoverageReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadCoverageReport( + LoadCoverageReport event, + Emitter emit, + ) async { + emit(CoverageLoading()); + try { + final CoverageReport report = await _reportsRepository.getCoverageReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(CoverageLoaded(report)); + } catch (e) { + emit(CoverageError(e.toString())); + } + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart new file mode 100644 index 00000000..546e648d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart @@ -0,0 +1,25 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; + +abstract class CoverageEvent extends Equatable { + const CoverageEvent(); + + @override + List get props => []; +} + +class LoadCoverageReport extends CoverageEvent { + + const LoadCoverageReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart new file mode 100644 index 00000000..109a0c4c --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class CoverageState extends Equatable { + const CoverageState(); + + @override + List get props => []; +} + +class CoverageInitial extends CoverageState {} + +class CoverageLoading extends CoverageState {} + +class CoverageLoaded extends CoverageState { + + const CoverageLoaded(this.report); + final CoverageReport report; + + @override + List get props => [report]; +} + +class CoverageError extends CoverageState { + + const CoverageError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart new file mode 100644 index 00000000..943553bb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/daily_ops_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'daily_ops_event.dart'; +import 'daily_ops_state.dart'; + +class DailyOpsBloc extends Bloc { + + DailyOpsBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(DailyOpsInitial()) { + on(_onLoadDailyOpsReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadDailyOpsReport( + LoadDailyOpsReport event, + Emitter emit, + ) async { + emit(DailyOpsLoading()); + try { + final DailyOpsReport report = await _reportsRepository.getDailyOpsReport( + businessId: event.businessId, + date: event.date, + ); + emit(DailyOpsLoaded(report)); + } catch (e) { + emit(DailyOpsError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart new file mode 100644 index 00000000..081d00bc --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +abstract class DailyOpsEvent extends Equatable { + const DailyOpsEvent(); + + @override + List get props => []; +} + +class LoadDailyOpsReport extends DailyOpsEvent { + + const LoadDailyOpsReport({ + this.businessId, + required this.date, + }); + final String? businessId; + final DateTime date; + + @override + List get props => [businessId, date]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart new file mode 100644 index 00000000..85fa3fee --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class DailyOpsState extends Equatable { + const DailyOpsState(); + + @override + List get props => []; +} + +class DailyOpsInitial extends DailyOpsState {} + +class DailyOpsLoading extends DailyOpsState {} + +class DailyOpsLoaded extends DailyOpsState { + + const DailyOpsLoaded(this.report); + final DailyOpsReport report; + + @override + List get props => [report]; +} + +class DailyOpsError extends DailyOpsState { + + const DailyOpsError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart new file mode 100644 index 00000000..23df8973 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart @@ -0,0 +1,32 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/forecast_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'forecast_event.dart'; +import 'forecast_state.dart'; + +class ForecastBloc extends Bloc { + + ForecastBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(ForecastInitial()) { + on(_onLoadForecastReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadForecastReport( + LoadForecastReport event, + Emitter emit, + ) async { + emit(ForecastLoading()); + try { + final ForecastReport report = await _reportsRepository.getForecastReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ForecastLoaded(report)); + } catch (e) { + emit(ForecastError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart new file mode 100644 index 00000000..0f68ecf1 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class ForecastEvent extends Equatable { + const ForecastEvent(); + + @override + List get props => []; +} + +class LoadForecastReport extends ForecastEvent { + + const LoadForecastReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart new file mode 100644 index 00000000..ae252a4e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class ForecastState extends Equatable { + const ForecastState(); + + @override + List get props => []; +} + +class ForecastInitial extends ForecastState {} + +class ForecastLoading extends ForecastState {} + +class ForecastLoaded extends ForecastState { + + const ForecastLoaded(this.report); + final ForecastReport report; + + @override + List get props => [report]; +} + +class ForecastError extends ForecastState { + + const ForecastError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart new file mode 100644 index 00000000..d8bd103e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart @@ -0,0 +1,32 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/no_show_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'no_show_event.dart'; +import 'no_show_state.dart'; + +class NoShowBloc extends Bloc { + + NoShowBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(NoShowInitial()) { + on(_onLoadNoShowReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadNoShowReport( + LoadNoShowReport event, + Emitter emit, + ) async { + emit(NoShowLoading()); + try { + final NoShowReport report = await _reportsRepository.getNoShowReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(NoShowLoaded(report)); + } catch (e) { + emit(NoShowError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart new file mode 100644 index 00000000..a09a53dc --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class NoShowEvent extends Equatable { + const NoShowEvent(); + + @override + List get props => []; +} + +class LoadNoShowReport extends NoShowEvent { + + const LoadNoShowReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart new file mode 100644 index 00000000..8e286465 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class NoShowState extends Equatable { + const NoShowState(); + + @override + List get props => []; +} + +class NoShowInitial extends NoShowState {} + +class NoShowLoading extends NoShowState {} + +class NoShowLoaded extends NoShowState { + + const NoShowLoaded(this.report); + final NoShowReport report; + + @override + List get props => [report]; +} + +class NoShowError extends NoShowState { + + const NoShowError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart new file mode 100644 index 00000000..b9978bd9 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart @@ -0,0 +1,32 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/performance_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'performance_event.dart'; +import 'performance_state.dart'; + +class PerformanceBloc extends Bloc { + + PerformanceBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(PerformanceInitial()) { + on(_onLoadPerformanceReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadPerformanceReport( + LoadPerformanceReport event, + Emitter emit, + ) async { + emit(PerformanceLoading()); + try { + final PerformanceReport report = await _reportsRepository.getPerformanceReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(PerformanceLoaded(report)); + } catch (e) { + emit(PerformanceError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart new file mode 100644 index 00000000..d203b7e7 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class PerformanceEvent extends Equatable { + const PerformanceEvent(); + + @override + List get props => []; +} + +class LoadPerformanceReport extends PerformanceEvent { + + const LoadPerformanceReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart new file mode 100644 index 00000000..e6ca9527 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class PerformanceState extends Equatable { + const PerformanceState(); + + @override + List get props => []; +} + +class PerformanceInitial extends PerformanceState {} + +class PerformanceLoading extends PerformanceState {} + +class PerformanceLoaded extends PerformanceState { + + const PerformanceLoaded(this.report); + final PerformanceReport report; + + @override + List get props => [report]; +} + +class PerformanceError extends PerformanceState { + + const PerformanceError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart new file mode 100644 index 00000000..c2e5f8ce --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart @@ -0,0 +1,32 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/spend_report.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'spend_event.dart'; +import 'spend_state.dart'; + +class SpendBloc extends Bloc { + + SpendBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(SpendInitial()) { + on(_onLoadSpendReport); + } + final ReportsRepository _reportsRepository; + + Future _onLoadSpendReport( + LoadSpendReport event, + Emitter emit, + ) async { + emit(SpendLoading()); + try { + final SpendReport report = await _reportsRepository.getSpendReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(SpendLoaded(report)); + } catch (e) { + emit(SpendError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart new file mode 100644 index 00000000..9802a0eb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class SpendEvent extends Equatable { + const SpendEvent(); + + @override + List get props => []; +} + +class LoadSpendReport extends SpendEvent { + + const LoadSpendReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart new file mode 100644 index 00000000..f8c949cd --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class SpendState extends Equatable { + const SpendState(); + + @override + List get props => []; +} + +class SpendInitial extends SpendState {} + +class SpendLoading extends SpendState {} + +class SpendLoaded extends SpendState { + + const SpendLoaded(this.report); + final SpendReport report; + + @override + List get props => [report]; +} + +class SpendError extends SpendState { + + const SpendError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart new file mode 100644 index 00000000..25c408ae --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart @@ -0,0 +1,32 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/src/entities/reports/reports_summary.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'reports_summary_event.dart'; +import 'reports_summary_state.dart'; + +class ReportsSummaryBloc extends Bloc { + + ReportsSummaryBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(ReportsSummaryInitial()) { + on(_onLoadReportsSummary); + } + final ReportsRepository _reportsRepository; + + Future _onLoadReportsSummary( + LoadReportsSummary event, + Emitter emit, + ) async { + emit(ReportsSummaryLoading()); + try { + final ReportsSummary summary = await _reportsRepository.getReportsSummary( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ReportsSummaryLoaded(summary)); + } catch (e) { + emit(ReportsSummaryError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart new file mode 100644 index 00000000..8753d5d0 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class ReportsSummaryEvent extends Equatable { + const ReportsSummaryEvent(); + + @override + List get props => []; +} + +class LoadReportsSummary extends ReportsSummaryEvent { + + const LoadReportsSummary({ + this.businessId, + required this.startDate, + required this.endDate, + }); + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart new file mode 100644 index 00000000..2772e415 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart @@ -0,0 +1,33 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class ReportsSummaryState extends Equatable { + const ReportsSummaryState(); + + @override + List get props => []; +} + +class ReportsSummaryInitial extends ReportsSummaryState {} + +class ReportsSummaryLoading extends ReportsSummaryState {} + +class ReportsSummaryLoaded extends ReportsSummaryState { + + const ReportsSummaryLoaded(this.summary); + final ReportsSummary summary; + + @override + List get props => [summary]; +} + +class ReportsSummaryError extends ReportsSummaryState { + + const ReportsSummaryError(this.message); + final String message; + + @override + List get props => [message]; +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart new file mode 100644 index 00000000..ca7c9f5e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -0,0 +1,302 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; +import 'package:krow_domain/krow_domain.dart'; +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:intl/intl.dart'; + +class CoverageReportPage extends StatefulWidget { + const CoverageReportPage({super.key}); + + @override + State createState() => _CoverageReportPageState(); +} + +class _CoverageReportPageState extends State { + final DateTime _startDate = DateTime.now(); + final DateTime _endDate = DateTime.now().add(const Duration(days: 14)); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, CoverageState state) { + if (state is CoverageLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CoverageError) { + return Center(child: Text(state.message)); + } + + if (state is CoverageLoaded) { + final CoverageReport report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.primary, UiColors.tagInProgress], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.coverage_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.coverage_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards + Row( + children: [ + Expanded( + child: _CoverageSummaryCard( + label: context.t.client_reports.coverage_report.metrics.avg_coverage, + value: '${report.overallCoverage.toStringAsFixed(1)}%', + icon: UiIcons.chart, + color: UiColors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _CoverageSummaryCard( + label: context.t.client_reports.coverage_report.metrics.full, + value: '${report.totalFilled}/${report.totalNeeded}', + icon: UiIcons.users, + color: UiColors.success, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Daily List + Text( + context.t.client_reports.coverage_report.next_7_days, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + if (report.dailyCoverage.isEmpty) + Center(child: Text(context.t.client_reports.coverage_report.empty_state)) + else + ...report.dailyCoverage.map((CoverageDay day) => _CoverageListItem( + date: DateFormat('EEE, MMM dd').format(day.date), + needed: day.needed, + filled: day.filled, + percentage: day.percentage, + )), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _CoverageSummaryCard extends StatelessWidget { + + const _CoverageSummaryCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: color), + ), + const SizedBox(height: 12), + Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _CoverageListItem extends StatelessWidget { + + const _CoverageListItem({ + required this.date, + required this.needed, + required this.filled, + required this.percentage, + }); + final String date; + final int needed; + final int filled; + final double percentage; + + @override + Widget build(BuildContext context) { + Color statusColor; + if (percentage >= 100) { + statusColor = UiColors.success; + } else if (percentage >= 80) { + statusColor = UiColors.textWarning; + } else { + statusColor = UiColors.destructive; + } + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + // Progress Bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: percentage / 100, + backgroundColor: UiColors.bgMenu, + valueColor: AlwaysStoppedAnimation(statusColor), + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$filled/$needed', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + '${percentage.toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ], + ), + ); + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart new file mode 100644 index 00000000..07ede38c --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -0,0 +1,620 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; +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:intl/intl.dart'; +import 'package:krow_domain/src/entities/reports/daily_ops_report.dart'; + +class DailyOpsReportPage extends StatefulWidget { + const DailyOpsReportPage({super.key}); + + @override + State createState() => _DailyOpsReportPageState(); +} + +class _DailyOpsReportPageState extends State { + DateTime _selectedDate = DateTime.now(); + + Future _pickDate(BuildContext context) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: UiColors.primary, + onPrimary: UiColors.white, + surface: UiColors.white, + onSurface: UiColors.textPrimary, + ), + ), + child: child!, + ); + }, + ); + if (picked != null && picked != _selectedDate && mounted) { + setState(() => _selectedDate = picked); + if (context.mounted) { + BlocProvider.of(context).add(LoadDailyOpsReport(date: picked)); + } + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadDailyOpsReport(date: _selectedDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, DailyOpsState state) { + if (state is DailyOpsLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is DailyOpsError) { + return Center(child: Text(state.message)); + } + + if (state is DailyOpsLoaded) { + final DailyOpsReport report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.daily_ops_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.daily_ops_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.daily_ops_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + UiIcons.download, + size: 14, + color: UiColors.primary, + ), + const SizedBox(width: 6), + Text( + context.t.client_reports.quick_reports + .export_all + .split(' ') + .first, + style: const TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date Selector + GestureDetector( + onTap: () => _pickDate(context), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + ), + ], + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 16, + color: UiColors.primary, + ), + const SizedBox(width: 8), + Text( + DateFormat('MMM dd, yyyy') + .format(_selectedDate), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + const Icon( + UiIcons.chevronDown, + size: 16, + color: UiColors.textSecondary, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Stats Grid + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.2, + children: [ + _OpsStatCard( + label: context.t.client_reports + .daily_ops_report.metrics.scheduled.label, + value: report.scheduledShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .scheduled + .sub_value, + color: UiColors.primary, + icon: UiIcons.calendar, + ), + _OpsStatCard( + label: context.t.client_reports + .daily_ops_report.metrics.workers.label, + value: report.workersConfirmed.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .workers + .sub_value, + color: UiColors.primary, + icon: UiIcons.users, + ), + _OpsStatCard( + label: context + .t + .client_reports + .daily_ops_report + .metrics + .in_progress + .label, + value: report.inProgressShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .in_progress + .sub_value, + color: UiColors.textWarning, + icon: UiIcons.clock, + ), + _OpsStatCard( + label: context + .t + .client_reports + .daily_ops_report + .metrics + .completed + .label, + value: report.completedShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .completed + .sub_value, + color: UiColors.success, + icon: UiIcons.checkCircle, + ), + ], + ), + + const SizedBox(height: 8), + Text( + context.t.client_reports.daily_ops_report + .all_shifts_title + .toUpperCase(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + + // Shift List + if (report.shifts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text(context.t.client_reports.daily_ops_report.no_shifts_today), + ), + ) + else + ...report.shifts.map((DailyOpsShift shift) => _ShiftListItem( + title: shift.title, + location: shift.location, + time: + '${DateFormat('HH:mm').format(shift.startTime)} - ${DateFormat('HH:mm').format(shift.endTime)}', + workers: + '${shift.filled}/${shift.workersNeeded}', + rate: shift.hourlyRate != null + ? '\$${shift.hourlyRate!.toStringAsFixed(0)}/hr' + : '-', + status: shift.status.replaceAll('_', ' '), + statusColor: shift.status == 'COMPLETED' + ? UiColors.success + : shift.status == 'IN_PROGRESS' + ? UiColors.textWarning + : UiColors.primary, + )), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _OpsStatCard extends StatelessWidget { + + const _OpsStatCard({ + required this.label, + required this.value, + required this.subValue, + required this.color, + required this.icon, + }); + final String label; + final String value; + final String subValue; + final Color color; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 6), + // Colored pill badge (matches prototype) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + subValue, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ShiftListItem extends StatelessWidget { + + const _ShiftListItem({ + required this.title, + required this.location, + required this.time, + required this.workers, + required this.rate, + required this.status, + required this.statusColor, + }); + final String title; + final String location; + final String time; + final String workers; + final String rate; + final String status; + final Color statusColor; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 2, + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + location, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + color: statusColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: UiColors.bgSecondary), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _infoItem( + context, + UiIcons.clock, + context.t.client_reports.daily_ops_report.shift_item.time, + time), + _infoItem( + context, + UiIcons.users, + context.t.client_reports.daily_ops_report.shift_item.workers, + workers), + _infoItem( + context, + UiIcons.trendingUp, + context.t.client_reports.daily_ops_report.shift_item.rate, + rate), + ], + ), + ], + ), + ); + } + + Widget _infoItem( + BuildContext context, IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 12, color: UiColors.textSecondary), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 10, color: UiColors.pinInactive), + ), + Text( + value, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: UiColors.textDescription, + ), + ), + ], + ), + ], + ); + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart new file mode 100644 index 00000000..553ca240 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -0,0 +1,477 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class ForecastReportPage extends StatefulWidget { + const ForecastReportPage({super.key}); + + @override + State createState() => _ForecastReportPageState(); +} + +class _ForecastReportPageState extends State { + final DateTime _startDate = DateTime.now(); + final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, ForecastState state) { + if (state is ForecastLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is ForecastError) { + return Center(child: Text(state.message)); + } + + if (state is ForecastLoaded) { + final ForecastReport report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + _buildHeader(context), + + // Content + Transform.translate( + offset: const Offset(0, -20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Metrics Grid + _buildMetricsGrid(context, report), + const SizedBox(height: 16), + + // Chart Section + _buildChartSection(context, report), + const SizedBox(height: 24), + + // Weekly Breakdown Title + Text( + context.t.client_reports.forecast_report.weekly_breakdown.title, + style: UiTypography.titleUppercase2m.textSecondary, + ), + const SizedBox(height: 12), + + // Weekly Breakdown List + if (report.weeklyBreakdown.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text( + context.t.client_reports.forecast_report.empty_state, + style: UiTypography.body2r.textSecondary, + ), + ), + ) + else + ...report.weeklyBreakdown.map( + (ForecastWeek week) => _WeeklyBreakdownItem(week: week), + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 40, + ), + decoration: const BoxDecoration( + color: UiColors.primary, + gradient: LinearGradient( + colors: [UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.forecast_report.title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + context.t.client_reports.forecast_report.subtitle, + style: UiTypography.body2m.copyWith( + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), +/* + UiButton.secondary( + text: context.t.client_reports.forecast_report.buttons.export, + leadingIcon: UiIcons.download, + onPressed: () { + // Placeholder export action + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.t.client_reports.forecast_report.placeholders.export_message), + ), + ); + }, + + // If button variants are limited, we might need a custom button or adjust design system usage + // Since I can't easily see UiButton implementation details beyond exports, I'll stick to a standard usage. + // If UiButton doesn't look right on blue bg, I count rely on it being white/transparent based on tokens. + ), +*/ + ], + ), + ); + } + + Widget _buildMetricsGrid(BuildContext context, ForecastReport report) { + final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report; + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.3, + children: [ + _MetricCard( + icon: UiIcons.dollar, + label: t.metrics.four_week_forecast, + value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend), + badgeText: t.badges.total_projected, + iconColor: UiColors.textWarning, + badgeColor: UiColors.tagPending, // Yellow-ish + ), + _MetricCard( + icon: UiIcons.trendingUp, + label: t.metrics.avg_weekly, + value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend), + badgeText: t.badges.per_week, + iconColor: UiColors.primary, + badgeColor: UiColors.tagInProgress, // Blue-ish + ), + _MetricCard( + icon: UiIcons.calendar, + label: t.metrics.total_shifts, + value: report.totalShifts.toString(), + badgeText: t.badges.scheduled, + iconColor: const Color(0xFF9333EA), // Purple + badgeColor: const Color(0xFFF3E8FF), // Purple light + ), + _MetricCard( + icon: UiIcons.users, + label: t.metrics.total_hours, + value: report.totalHours.toStringAsFixed(0), + badgeText: t.badges.worker_hours, + iconColor: UiColors.success, + badgeColor: UiColors.tagSuccess, + ), + ], + ); + } + + Widget _buildChartSection(BuildContext context, ForecastReport report) { + return Container( + height: 320, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.forecast_report.chart_title, + style: UiTypography.headline4m, + ), + const SizedBox(height: 8), + Text( + r'$15k', // Example Y-axis label placeholder or dynamic max + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(height: 24), + Expanded( + child: _ForecastChart(points: report.chartData), + ), + const SizedBox(height: 8), + // X Axis labels manually if chart doesn't handle them perfectly or for custom look + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('W1', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), + Text('W1', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer + Text('W2', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), + Text('W2', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer + Text('W3', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), + Text('W3', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer + Text('W4', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), + ], + ), + ], + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + + const _MetricCard({ + required this.icon, + required this.label, + required this.value, + required this.badgeText, + required this.iconColor, + required this.badgeColor, + }); + final IconData icon; + final String label; + final String value; + final String badgeText; + final Color iconColor; + final Color badgeColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 8, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + Text( + value, + style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + badgeText, + style: UiTypography.footnote1r.copyWith( + color: UiColors.textPrimary, // Or specific text color + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _WeeklyBreakdownItem extends StatelessWidget { + + const _WeeklyBreakdownItem({required this.week}); + final ForecastWeek week; + + @override + Widget build(BuildContext context) { + final TranslationsClientReportsForecastReportWeeklyBreakdownEn t = context.t.client_reports.forecast_report.weekly_breakdown; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.week(index: week.weekNumber), + style: UiTypography.headline4m, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: UiColors.tagPending, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.totalCost), + style: UiTypography.body2b.copyWith( + color: UiColors.textWarning, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildStat(t.shifts, week.shiftsCount.toString()), + _buildStat(t.hours, week.hoursCount.toStringAsFixed(0)), + _buildStat(t.avg_shift, NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.avgCostPerShift)), + ], + ), + ], + ), + ); + } + + Widget _buildStat(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), + const SizedBox(height: 4), + Text(value, style: UiTypography.body1m), + ], + ); + } +} + +class _ForecastChart extends StatelessWidget { + + const _ForecastChart({required this.points}); + final List points; + + @override + Widget build(BuildContext context) { + // If no data, show empty or default line? + if (points.isEmpty) return const SizedBox(); + + return LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 5000, // Dynamic? + getDrawingHorizontalLine: (double value) { + return const FlLine( + color: UiColors.borderInactive, + strokeWidth: 1, + dashArray: [5, 5], + ); + }, + ), + titlesData: const FlTitlesData(show: false), + borderData: FlBorderData(show: false), + minX: 0, + maxX: points.length.toDouble() - 1, + // minY: 0, // Let it scale automatically + lineBarsData: [ + LineChartBarData( + spots: points.asMap().entries.map((MapEntry e) { + return FlSpot(e.key.toDouble(), e.value.projectedCost); + }).toList(), + isCurved: true, + color: UiColors.textWarning, // Orange-ish + barWidth: 4, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (FlSpot spot, double percent, LineChartBarData barData, int index) { + return FlDotCirclePainter( + radius: 4, + color: UiColors.textWarning, + strokeWidth: 2, + strokeColor: UiColors.white, + ); + }, + ), + belowBarData: BarAreaData( + show: true, + color: UiColors.tagPending.withOpacity(0.5), // Light orange fill + ), + ), + ], + ), + ); + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart new file mode 100644 index 00000000..299ea0ca --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -0,0 +1,452 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:krow_domain/krow_domain.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; +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:intl/intl.dart'; + +class NoShowReportPage extends StatefulWidget { + const NoShowReportPage({super.key}); + + @override + State createState() => _NoShowReportPageState(); +} + +class _NoShowReportPageState extends State { + final DateTime _startDate = DateTime.now().subtract(const Duration(days: 30)); + final DateTime _endDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, NoShowState state) { + if (state is NoShowLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is NoShowError) { + return Center(child: Text(state.message)); + } + + if (state is NoShowLoaded) { + final NoShowReport report = state.report; + final int uniqueWorkers = report.flaggedWorkers.length; + return SingleChildScrollView( + child: Column( + children: [ + // รขโ€โ‚ฌรขโ€โ‚ฌ Header รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.no_show_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.no_show_report.subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.6), + ), + ), + ], + ), + ], + ), + // Export button +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export coming soon'), + duration: Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon( + UiIcons.download, + size: 14, + color: Color(0xFF1A1A2E), + ), + SizedBox(width: 6), + Text( + 'Export', + style: TextStyle( + color: Color(0xFF1A1A2E), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Content รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 3-chip summary row (matches prototype) + Row( + children: [ + Expanded( + child: _SummaryChip( + icon: UiIcons.warning, + iconColor: UiColors.error, + label: context.t.client_reports.no_show_report.metrics.no_shows, + value: report.totalNoShows.toString(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryChip( + icon: UiIcons.trendingUp, + iconColor: UiColors.textWarning, + label: context.t.client_reports.no_show_report.metrics.rate, + value: + '${report.noShowRate.toStringAsFixed(1)}%', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryChip( + icon: UiIcons.user, + iconColor: UiColors.primary, + label: context.t.client_reports.no_show_report.metrics.workers, + value: uniqueWorkers.toString(), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Section title + Text( + context.t.client_reports.no_show_report + .workers_list_title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + + // Worker cards with risk badges + if (report.flaggedWorkers.isEmpty) + Container( + padding: const EdgeInsets.all(40), + alignment: Alignment.center, + child: Text( + context.t.client_reports.no_show_report.empty_state, + style: const TextStyle( + color: UiColors.textSecondary, + ), + ), + ) + else + ...report.flaggedWorkers.map( + (NoShowWorker worker) => _WorkerCard(worker: worker), + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +// รขโ€โ‚ฌรขโ€โ‚ฌ Summary chip (top 3 stats) รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ +class _SummaryChip extends StatelessWidget { + + const _SummaryChip({ + required this.icon, + required this.iconColor, + required this.label, + required this.value, + }); + final IconData icon; + final Color iconColor; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 12, color: iconColor), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 10, + color: iconColor, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +// รขโ€โ‚ฌรขโ€โ‚ฌ Worker card with risk badge + latest incident รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ +class _WorkerCard extends StatelessWidget { + + const _WorkerCard({required this.worker}); + final NoShowWorker worker; + + String _riskLabel(BuildContext context, int count) { + if (count >= 3) return context.t.client_reports.no_show_report.risks.high; + if (count == 2) return context.t.client_reports.no_show_report.risks.medium; + return context.t.client_reports.no_show_report.risks.low; + } + + Color _riskColor(int count) { + if (count >= 3) return UiColors.error; + if (count == 2) return UiColors.textWarning; + return UiColors.success; + } + + Color _riskBg(int count) { + if (count >= 3) return UiColors.tagError; + if (count == 2) return UiColors.tagPending; + return UiColors.tagSuccess; + } + + @override + Widget build(BuildContext context) { + final String riskLabel = _riskLabel(context, worker.noShowCount); + final Color riskColor = _riskColor(worker.noShowCount); + final Color riskBg = _riskBg(worker.noShowCount); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 6, + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.user, + color: UiColors.textSecondary, + size: 20, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + worker.fullName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + Text( + context.t.client_reports.no_show_report.no_show_count(count: worker.noShowCount.toString()), + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + // Risk badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: riskBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + riskLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: riskColor, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: UiColors.bgSecondary), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.t.client_reports.no_show_report.latest_incident, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + Text( + // Use reliabilityScore as a proxy for last incident date offset + DateFormat('MMM dd, yyyy').format( + DateTime.now().subtract( + Duration( + days: ((1.0 - worker.reliabilityScore) * 60).round(), + ), + ), + ), + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ); + } +} + +// รขโ€โ‚ฌรขโ€โ‚ฌ Insight line รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart new file mode 100644 index 00000000..f43b3cd8 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -0,0 +1,489 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; +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_domain/src/entities/reports/performance_report.dart'; + +class PerformanceReportPage extends StatefulWidget { + const PerformanceReportPage({super.key}); + + @override + State createState() => _PerformanceReportPageState(); +} + +class _PerformanceReportPageState extends State { + final DateTime _startDate = DateTime.now().subtract(const Duration(days: 30)); + final DateTime _endDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, PerformanceState state) { + if (state is PerformanceLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is PerformanceError) { + return Center(child: Text(state.message)); + } + + if (state is PerformanceLoaded) { + final PerformanceReport report = state.report; + + // Compute overall score (0รขโ‚ฌโ€œ100) from the 4 KPIs + final double overallScore = ((report.fillRate * 0.3) + + (report.completionRate * 0.3) + + (report.onTimeRate * 0.25) + + // avg fill time: 3h target รขโ€ โ€™ invert to score + ((report.avgFillTimeHours <= 3 + ? 100 + : (3 / report.avgFillTimeHours) * 100) * + 0.15)) + .clamp(0.0, 100.0); + + final String scoreLabel = overallScore >= 90 + ? context.t.client_reports.performance_report.overall_score.excellent + : overallScore >= 75 + ? context.t.client_reports.performance_report.overall_score.good + : context.t.client_reports.performance_report.overall_score.needs_work; + final Color scoreLabelColor = overallScore >= 90 + ? UiColors.success + : overallScore >= 75 + ? UiColors.textWarning + : UiColors.error; + final Color scoreLabelBg = overallScore >= 90 + ? UiColors.tagSuccess + : overallScore >= 75 + ? UiColors.tagPending + : UiColors.tagError; + + // KPI rows: label, value, target, color, met status + final List<_KpiData> kpis = <_KpiData>[ + _KpiData( + icon: UiIcons.users, + iconColor: UiColors.primary, + label: context.t.client_reports.performance_report.kpis.fill_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '95'), + value: report.fillRate, + displayValue: '${report.fillRate.toStringAsFixed(0)}%', + barColor: UiColors.primary, + met: report.fillRate >= 95, + close: report.fillRate >= 90, + ), + _KpiData( + icon: UiIcons.checkCircle, + iconColor: UiColors.success, + label: context.t.client_reports.performance_report.kpis.completion_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '98'), + value: report.completionRate, + displayValue: '${report.completionRate.toStringAsFixed(0)}%', + barColor: UiColors.success, + met: report.completionRate >= 98, + close: report.completionRate >= 93, + ), + _KpiData( + icon: UiIcons.clock, + iconColor: const Color(0xFF9B59B6), + label: context.t.client_reports.performance_report.kpis.on_time_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), + value: report.onTimeRate, + displayValue: '${report.onTimeRate.toStringAsFixed(0)}%', + barColor: const Color(0xFF9B59B6), + met: report.onTimeRate >= 97, + close: report.onTimeRate >= 92, + ), + _KpiData( + icon: UiIcons.trendingUp, + iconColor: const Color(0xFFF39C12), + label: context.t.client_reports.performance_report.kpis.avg_fill_time, + target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), + // invert: lower is better รขโ‚ฌโ€ show as % of target met + value: report.avgFillTimeHours == 0 + ? 100 + : (3 / report.avgFillTimeHours * 100).clamp(0, 100), + displayValue: + '${report.avgFillTimeHours.toStringAsFixed(1)} hrs', + barColor: const Color(0xFFF39C12), + met: report.avgFillTimeHours <= 3, + close: report.avgFillTimeHours <= 4, + ), + ]; + + return SingleChildScrollView( + child: Column( + children: [ + // รขโ€โ‚ฌรขโ€โ‚ฌ Header รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.performance_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.performance_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + // Export +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export coming soon'), + duration: Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(UiIcons.download, + size: 14, color: UiColors.primary), + SizedBox(width: 6), + Text( + 'Export', + style: TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Content รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // รขโ€โ‚ฌรขโ€โ‚ฌ Overall Score Hero Card รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 32, + horizontal: 20, + ), + decoration: BoxDecoration( + color: const Color(0xFFF0F4FF), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + const Icon( + UiIcons.chart, + size: 32, + color: UiColors.primary, + ), + const SizedBox(height: 12), + Text( + context.t.client_reports.performance_report.overall_score.title, + style: const TextStyle( + fontSize: 13, + color: UiColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + '${overallScore.toStringAsFixed(0)}/100', + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: UiColors.primary, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + decoration: BoxDecoration( + color: scoreLabelBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + scoreLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: scoreLabelColor, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // รขโ€โ‚ฌรขโ€โ‚ฌ KPI List รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.performance_report.kpis_title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 20), + ...kpis.map( + (_KpiData kpi) => _KpiRow(kpi: kpi), + ), + ], + ), + ), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +// รขโ€โ‚ฌรขโ€โ‚ฌ KPI data model รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ +class _KpiData { + + const _KpiData({ + required this.icon, + required this.iconColor, + required this.label, + required this.target, + required this.value, + required this.displayValue, + required this.barColor, + required this.met, + required this.close, + }); + final IconData icon; + final Color iconColor; + final String label; + final String target; + final double value; // 0รขโ‚ฌโ€œ100 for bar + final String displayValue; + final Color barColor; + final bool met; + final bool close; +} + +// รขโ€โ‚ฌรขโ€โ‚ฌ KPI row widget รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ +class _KpiRow extends StatelessWidget { + + const _KpiRow({required this.kpi}); + final _KpiData kpi; + + @override + Widget build(BuildContext context) { + final String badgeText = kpi.met + ? context.t.client_reports.performance_report.kpis.met + : kpi.close + ? context.t.client_reports.performance_report.kpis.close + : context.t.client_reports.performance_report.kpis.miss; + final Color badgeColor = kpi.met + ? UiColors.success + : kpi.close + ? UiColors.textWarning + : UiColors.error; + final Color badgeBg = kpi.met + ? UiColors.tagSuccess + : kpi.close + ? UiColors.tagPending + : UiColors.tagError; + + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: kpi.iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(kpi.icon, size: 18, color: kpi.iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + kpi.label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + Text( + kpi.target, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + ], + ), + ), + // Value + badge inline (matches prototype) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + kpi.displayValue, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badgeText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: badgeColor, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (kpi.value / 100).clamp(0.0, 1.0), + backgroundColor: UiColors.bgSecondary, + valueColor: AlwaysStoppedAnimation(kpi.barColor), + minHeight: 6, + ), + ), + ], + ), + ); + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart new file mode 100644 index 00000000..10a6c620 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -0,0 +1,120 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.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 '../widgets/reports_page/index.dart'; + +/// The main Reports page for the client application. +/// +/// Displays key performance metrics and quick access to various reports. +/// Handles tab-based time period selection (Today, Week, Month, Quarter). +class ReportsPage extends StatefulWidget { + const ReportsPage({super.key}); + + @override + State createState() => _ReportsPageState(); +} + +class _ReportsPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late ReportsSummaryBloc _summaryBloc; + + // Date ranges per tab: Today, Week, Month, Quarter + final List<(DateTime, DateTime)> _dateRanges = <(DateTime, DateTime)>[ + ( + DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day), + DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day, + 23, 59, 59), + ), + ( + DateTime.now().subtract(const Duration(days: 7)), + DateTime.now(), + ), + ( + DateTime(DateTime.now().year, DateTime.now().month, 1), + DateTime.now(), + ), + ( + DateTime( + DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, 1), + DateTime.now(), + ), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _summaryBloc = Modular.get(); + _loadSummary(0); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + _loadSummary(_tabController.index); + } + }); + } + + void _loadSummary(int tabIndex) { + final (DateTime, DateTime) range = _dateRanges[tabIndex]; + _summaryBloc.add(LoadReportsSummary( + startDate: range.$1, + endDate: range.$2, + )); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _summaryBloc, + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: SingleChildScrollView( + child: Column( + children: [ + // Header with title and tabs + ReportsHeader( + tabController: _tabController, + onTabChanged: _loadSummary, + ), + + // Content + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Key Metrics Grid + MetricsGrid(), + + SizedBox(height: 16), + + // Quick Reports Section + QuickReportsSection(), + + SizedBox(height: 88), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart new file mode 100644 index 00000000..9b6becd6 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -0,0 +1,551 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +class SpendReportPage extends StatefulWidget { + const SpendReportPage({super.key}); + + @override + State createState() => _SpendReportPageState(); +} + +class _SpendReportPageState extends State { + late DateTime _startDate; + late DateTime _endDate; + + @override + void initState() { + super.initState(); + final DateTime now = DateTime.now(); + // Monday alignment logic + final int diff = now.weekday - DateTime.monday; + final DateTime monday = now.subtract(Duration(days: diff)); + _startDate = DateTime(monday.year, monday.month, monday.day); + _endDate = _startDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(LoadSpendReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (BuildContext context, SpendState state) { + if (state is SpendLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is SpendError) { + return Center(child: Text(state.message)); + } + + if (state is SpendLoaded) { + final SpendReport report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 80, // Overlap space + ), + decoration: const BoxDecoration( + color: UiColors.primary, // Blue background per prototype + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.spend_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.spend_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + UiIcons.download, + size: 14, + color: UiColors.primary, + ), + const SizedBox(width: 6), + Text( + context.t.client_reports.quick_reports + .export_all + .split(' ') + .first, + style: const TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -60), // Pull up to overlap + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards (New Style) + Row( + children: [ + Expanded( + child: _SpendStatCard( + label: context.t.client_reports.spend_report + .summary.total_spend, + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) + .format(report.totalSpend), + pillText: context.t.client_reports + .spend_report.summary.this_week, + themeColor: UiColors.success, + icon: UiIcons.dollar, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SpendStatCard( + label: context.t.client_reports.spend_report + .summary.avg_daily, + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) + .format(report.averageCost), + pillText: context.t.client_reports + .spend_report.summary.per_day, + themeColor: UiColors.primary, + icon: UiIcons.trendingUp, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Daily Spend Trend Chart + Container( + height: 320, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.chart_title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 32), + Expanded( + child: _SpendBarChart( + chartData: report.chartData), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Spend by Industry + _SpendByIndustryCard( + industries: report.industryBreakdown, + ), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _SpendBarChart extends StatelessWidget { + + const _SpendBarChart({required this.chartData}); + final List chartData; + + @override + Widget build(BuildContext context) { + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: (chartData.fold(0, + (double prev, element) => + element.amount > prev ? element.amount : prev) * + 1.2) + .ceilToDouble(), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + tooltipPadding: const EdgeInsets.all(8), + getTooltipItem: (BarChartGroupData group, int groupIndex, BarChartRodData rod, int rodIndex) { + return BarTooltipItem( + '\$${rod.toY.round()}', + const TextStyle( + color: UiColors.white, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (double value, TitleMeta meta) { + if (value.toInt() >= chartData.length) return const SizedBox(); + final date = chartData[value.toInt()].date; + return SideTitleWidget( + axisSide: meta.axisSide, + space: 8, + child: Text( + DateFormat('E').format(date), + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 11, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (double value, TitleMeta meta) { + if (value == 0) return const SizedBox(); + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + '\$${(value / 1000).toStringAsFixed(0)}k', + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 10, + ), + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 1000, + getDrawingHorizontalLine: (double value) => const FlLine( + color: UiColors.bgSecondary, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: List.generate( + chartData.length, + (int index) => BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: chartData[index].amount, + color: UiColors.success, + width: 12, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SpendStatCard extends StatelessWidget { + + const _SpendStatCard({ + required this.label, + required this.value, + required this.pillText, + required this.themeColor, + required this.icon, + }); + final String label; + final String value; + final String pillText; + final Color themeColor; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: themeColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: themeColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + pillText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: themeColor, + ), + ), + ), + ], + ), + ); + } +} + +class _SpendByIndustryCard extends StatelessWidget { + + const _SpendByIndustryCard({required this.industries}); + final List industries; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.spend_by_industry, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 24), + if (industries.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + context.t.client_reports.spend_report.no_industry_data, + style: const TextStyle(color: UiColors.textSecondary), + ), + ), + ) + else + ...industries.map((SpendIndustryCategory ind) => Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + ind.name, + style: const TextStyle( + fontSize: 13, + color: UiColors.textSecondary, + ), + ), + Text( + NumberFormat.currency(symbol: r'$', decimalDigits: 0) + .format(ind.amount), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ind.percentage / 100, + backgroundColor: UiColors.bgSecondary, + color: UiColors.success, + minHeight: 6, + ), + ), + const SizedBox(height: 6), + Text( + context.t.client_reports.spend_report.percent_total(percent: ind.percentage.toStringAsFixed(1)), + style: const TextStyle( + fontSize: 10, + color: UiColors.textDescription, + ), + ), + ], + ), + )), + ], + ), + ); + } +} + diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart new file mode 100644 index 00000000..58d67814 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart @@ -0,0 +1,5 @@ +export 'metric_card.dart'; +export 'metrics_grid.dart'; +export 'quick_reports_section.dart'; +export 'report_card.dart'; +export 'reports_header.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart new file mode 100644 index 00000000..3040f6ed --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart @@ -0,0 +1,108 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A metric card widget for displaying key performance indicators. +/// +/// Shows a metric with an icon, label, value, and a badge with contextual +/// information. Used in the metrics grid of the reports page. +class MetricCard extends StatelessWidget { + const MetricCard({ + super.key, + required this.icon, + required this.label, + required this.value, + required this.badgeText, + required this.badgeColor, + required this.badgeTextColor, + required this.iconColor, + }); + + /// The icon to display for this metric. + final IconData icon; + + /// The label describing the metric. + final String label; + + /// The main value to display (e.g., "1.2k", "$50,000"). + final String value; + + /// Text to display in the badge. + final String badgeText; + + /// Background color for the badge. + final Color badgeColor; + + /// Text color for the badge. + final Color badgeTextColor; + + /// Color for the icon. + final Color iconColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: UiColors.border, + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Icon and Label + Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: UiTypography.body2r, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + // Value and Badge + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: UiConstants.radiusMd, + border: Border.all( + color: badgeTextColor, + width: 0.25, + ), + ), + child: Text( + badgeText, + style: UiTypography.footnote2m.copyWith( + color: badgeTextColor, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart new file mode 100644 index 00000000..e90d081a --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart @@ -0,0 +1,150 @@ +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; +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:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'metric_card.dart'; + +/// A grid of key metrics driven by the ReportsSummaryBloc. +/// +/// Displays 6 metrics in a 2-column grid: +/// - Total Hours +/// - OT Hours +/// - Total Spend +/// - Fill Rate +/// - Average Fill Time +/// - No-Show Rate +/// +/// Handles loading, error, and success states. +class MetricsGrid extends StatelessWidget { + const MetricsGrid({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, ReportsSummaryState state) { + // Loading or Initial State + if (state is ReportsSummaryLoading || state is ReportsSummaryInitial) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + + // Error State + if (state is ReportsSummaryError) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.tagError, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(UiIcons.warning, color: UiColors.error, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + state.message, + style: const TextStyle(color: UiColors.error, fontSize: 12), + ), + ), + ], + ), + ); + } + + // Loaded State + final ReportsSummary summary = (state as ReportsSummaryLoaded).summary; + final NumberFormat currencyFmt = + NumberFormat.currency(symbol: '\$', decimalDigits: 0); + + return GridView.count( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space6, + ), + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.32, + children: [ + // Total Hour + MetricCard( + icon: UiIcons.clock, + label: context.t.client_reports.metrics.total_hrs.label, + value: summary.totalHours >= 1000 + ? '${(summary.totalHours / 1000).toStringAsFixed(1)}k' + : summary.totalHours.toStringAsFixed(0), + badgeText: context.t.client_reports.metrics.total_hrs.badge, + badgeColor: UiColors.tagRefunded, + badgeTextColor: UiColors.primary, + iconColor: UiColors.primary, + ), + // OT Hours + MetricCard( + icon: UiIcons.trendingUp, + label: context.t.client_reports.metrics.ot_hours.label, + value: summary.otHours.toStringAsFixed(0), + badgeText: context.t.client_reports.metrics.ot_hours.badge, + badgeColor: UiColors.tagValue, + badgeTextColor: UiColors.textSecondary, + iconColor: UiColors.textWarning, + ), + // Total Spend + MetricCard( + icon: UiIcons.dollar, + label: context.t.client_reports.metrics.total_spend.label, + value: summary.totalSpend >= 1000 + ? '\$${(summary.totalSpend / 1000).toStringAsFixed(1)}k' + : currencyFmt.format(summary.totalSpend), + badgeText: context.t.client_reports.metrics.total_spend.badge, + badgeColor: UiColors.tagSuccess, + badgeTextColor: UiColors.textSuccess, + iconColor: UiColors.success, + ), + // Fill Rate + MetricCard( + icon: UiIcons.trendingUp, + label: context.t.client_reports.metrics.fill_rate.label, + value: '${summary.fillRate.toStringAsFixed(0)}%', + badgeText: context.t.client_reports.metrics.fill_rate.badge, + badgeColor: UiColors.tagInProgress, + badgeTextColor: UiColors.textLink, + iconColor: UiColors.iconActive, + ), + // Average Fill Time + MetricCard( + icon: UiIcons.clock, + label: context.t.client_reports.metrics.avg_fill_time.label, + value: '${summary.avgFillTimeHours.toStringAsFixed(1)} hrs', + badgeText: context.t.client_reports.metrics.avg_fill_time.badge, + badgeColor: UiColors.tagInProgress, + badgeTextColor: UiColors.textLink, + iconColor: UiColors.iconActive, + ), + // No-Show Rate + MetricCard( + icon: UiIcons.warning, + label: context.t.client_reports.metrics.no_show_rate.label, + value: '${summary.noShowRate.toStringAsFixed(1)}%', + badgeText: context.t.client_reports.metrics.no_show_rate.badge, + badgeColor: summary.noShowRate < 5 + ? UiColors.tagSuccess + : UiColors.tagError, + badgeTextColor: summary.noShowRate < 5 + ? UiColors.textSuccess + : UiColors.error, + iconColor: UiColors.destructive, + ), + ], + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart new file mode 100644 index 00000000..5ca80eb6 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart @@ -0,0 +1,94 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'report_card.dart'; + +/// A section displaying quick access report cards. +/// +/// Shows 4 quick report cards for: +/// - Daily Operations +/// - Spend Analysis +/// - No-Show Rates +/// - Performance Reports +class QuickReportsSection extends StatelessWidget { + const QuickReportsSection({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + context.t.client_reports.quick_reports.title, + style: UiTypography.headline2m.textPrimary, + ), + + // Quick Reports Grid + GridView.count( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space6, + ), + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.3, + children: [ + // Daily Operations + ReportCard( + icon: UiIcons.calendar, + name: context.t.client_reports.quick_reports.cards.daily_ops, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './daily-ops', + ), + // Spend Analysis + ReportCard( + icon: UiIcons.dollar, + name: context.t.client_reports.quick_reports.cards.spend, + iconBgColor: UiColors.tagSuccess, + iconColor: UiColors.success, + route: './spend', + ), + // Coverage Report + ReportCard( + icon: UiIcons.users, + name: context.t.client_reports.quick_reports.cards.coverage, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './coverage', + ), + // No-Show Rates + ReportCard( + icon: UiIcons.warning, + name: context.t.client_reports.quick_reports.cards.no_show, + iconBgColor: UiColors.tagError, + iconColor: UiColors.destructive, + route: './no-show', + ), + // Forecast Report + ReportCard( + icon: UiIcons.trendingUp, + name: context.t.client_reports.quick_reports.cards.forecast, + iconBgColor: UiColors.tagPending, + iconColor: UiColors.textWarning, + route: './forecast', + ), + // Performance Reports + ReportCard( + icon: UiIcons.chart, + name: context.t.client_reports.quick_reports.cards.performance, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './performance', + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart new file mode 100644 index 00000000..5ef00fcb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart @@ -0,0 +1,105 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +/// A quick report card widget for navigating to specific reports. +/// +/// Displays an icon, name, and a quick navigation to a report page. +/// Used in the quick reports grid of the reports page. +class ReportCard extends StatelessWidget { + + const ReportCard({ + super.key, + required this.icon, + required this.name, + required this.iconBgColor, + required this.iconColor, + required this.route, + }); + /// The icon to display for this report. + final IconData icon; + + /// The name/title of the report. + final String name; + + /// Background color for the icon container. + final Color iconBgColor; + + /// Color for the icon. + final Color iconColor; + + /// Navigation route to the report page. + final String route; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Modular.to.pushNamed(route), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 2, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Icon Container + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 20, color: iconColor), + ), + // Name and Export Info + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + UiIcons.download, + size: 12, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + context.t.client_reports.quick_reports + .two_click_export, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart new file mode 100644 index 00000000..124a2c35 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart @@ -0,0 +1,116 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +/// Header widget for the Reports page. +/// +/// Displays the title, back button, and tab selector for different time periods +/// (Today, Week, Month, Quarter). +class ReportsHeader extends StatelessWidget { + const ReportsHeader({ + super.key, + required this.onTabChanged, + required this.tabController, + }); + + /// Called when a tab is selected. + final Function(int) onTabChanged; + + /// The current tab controller for managing tab state. + final TabController tabController; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + // Title and Back Button + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Text( + context.t.client_reports.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + ], + ), + const SizedBox(height: 24), + // Tab Bar + _buildTabBar(context), + ], + ), + ); + } + + /// Builds the styled tab bar for time period selection. + Widget _buildTabBar(BuildContext context) { + return Container( + height: 44, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: tabController, + onTap: onTabChanged, + indicator: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + labelColor: UiColors.primary, + unselectedLabelColor: UiColors.white, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + tabs: [ + Tab(text: context.t.client_reports.tabs.today), + Tab(text: context.t.client_reports.tabs.week), + Tab(text: context.t.client_reports.tabs.month), + Tab(text: context.t.client_reports.tabs.quarter), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart new file mode 100644 index 00000000..9042127e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -0,0 +1,48 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart'; +import 'package:client_reports/src/presentation/pages/forecast_report_page.dart'; +import 'package:client_reports/src/presentation/pages/no_show_report_page.dart'; +import 'package:client_reports/src/presentation/pages/performance_report_page.dart'; +import 'package:client_reports/src/presentation/pages/reports_page.dart'; +import 'package:client_reports/src/presentation/pages/spend_report_page.dart'; +import 'package:client_reports/src/presentation/pages/coverage_report_page.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +class ReportsModule extends Module { + @override + List get imports => [DataConnectModule()]; + + @override + void binds(Injector i) { + i.addLazySingleton(ReportsRepositoryImpl.new); + i.add(DailyOpsBloc.new); + i.add(SpendBloc.new); + i.add(CoverageBloc.new); + i.add(ForecastBloc.new); + i.add(PerformanceBloc.new); + i.add(NoShowBloc.new); + i.add(ReportsSummaryBloc.new); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (_) => const ReportsPage()); + r.child('/daily-ops', child: (_) => const DailyOpsReportPage()); + r.child('/spend', child: (_) => const SpendReportPage()); + r.child('/coverage', child: (_) => const CoverageReportPage()); + r.child('/forecast', child: (_) => const ForecastReportPage()); + r.child('/performance', child: (_) => const PerformanceReportPage()); + r.child('/no-show', child: (_) => const NoShowReportPage()); + } +} + diff --git a/apps/mobile/packages/features/client/reports/pubspec.yaml b/apps/mobile/packages/features/client/reports/pubspec.yaml new file mode 100644 index 00000000..f4807bd9 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/pubspec.yaml @@ -0,0 +1,39 @@ +name: client_reports +description: Workforce reports and analytics for client application +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + # Dependencies needed for the prototype + # lucide_icons removed, used via design_system + fl_chart: ^0.66.0 + + # Internal packages + design_system: + path: ../../../design_system + krow_domain: + path: ../../../domain + krow_core: + path: ../../../core + core_localization: + path: ../../../core_localization + krow_data_connect: + path: ../../../data_connect + + # External packages + flutter_modular: ^6.3.4 + flutter_bloc: ^8.1.6 + equatable: ^2.0.7 + intl: ^0.20.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/apps/mobile/packages/features/client/settings/lib/client_settings.dart b/apps/mobile/packages/features/client/settings/lib/client_settings.dart index 90cb283e..05a38348 100644 --- a/apps/mobile/packages/features/client/settings/lib/client_settings.dart +++ b/apps/mobile/packages/features/client/settings/lib/client_settings.dart @@ -6,6 +6,7 @@ import 'src/domain/repositories/settings_repository_interface.dart'; import 'src/domain/usecases/sign_out_usecase.dart'; import 'src/presentation/blocs/client_settings_bloc.dart'; import 'src/presentation/pages/client_settings_page.dart'; +import 'src/presentation/pages/edit_profile_page.dart'; /// A [Module] for the client settings feature. class ClientSettingsModule extends Module { @@ -30,5 +31,9 @@ class ClientSettingsModule extends Module { ClientPaths.childRoute(ClientPaths.settings, ClientPaths.settings), child: (_) => const ClientSettingsPage(), ); + r.child( + '/edit-profile', + child: (_) => const EditProfilePage(), + ); } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/client/settings/lib/src/domain/usecases/sign_out_usecase.dart index 3f050dfc..5ca30507 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/domain/usecases/sign_out_usecase.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/domain/usecases/sign_out_usecase.dart @@ -5,12 +5,12 @@ import '../repositories/settings_repository_interface.dart'; /// /// This use case delegates the sign out logic to the [SettingsRepositoryInterface]. class SignOutUseCase implements NoInputUseCase { - final SettingsRepositoryInterface _repository; /// Creates a [SignOutUseCase]. /// /// Requires a [SettingsRepositoryInterface] to perform the sign out operation. SignOutUseCase(this._repository); + final SettingsRepositoryInterface _repository; @override Future call() { diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_bloc.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_bloc.dart index 7f2506b0..37223a02 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_bloc.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_bloc.dart @@ -9,12 +9,26 @@ part 'client_settings_state.dart'; /// BLoC to manage client settings and profile state. class ClientSettingsBloc extends Bloc with BlocErrorHandler { - final SignOutUseCase _signOutUseCase; ClientSettingsBloc({required SignOutUseCase signOutUseCase}) : _signOutUseCase = signOutUseCase, super(const ClientSettingsInitial()) { on(_onSignOutRequested); + on(_onNotificationToggled); + } + final SignOutUseCase _signOutUseCase; + + void _onNotificationToggled( + ClientSettingsNotificationToggled event, + Emitter emit, + ) { + if (event.type == 'push') { + emit(state.copyWith(pushEnabled: event.isEnabled)); + } else if (event.type == 'email') { + emit(state.copyWith(emailEnabled: event.isEnabled)); + } else if (event.type == 'sms') { + emit(state.copyWith(smsEnabled: event.isEnabled)); + } } Future _onSignOutRequested( @@ -23,7 +37,7 @@ class ClientSettingsBloc extends Bloc ) async { emit(const ClientSettingsLoading()); await handleError( - emit: emit, + emit: emit.call, action: () async { await _signOutUseCase(); emit(const ClientSettingsSignOutSuccess()); diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_event.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_event.dart index 8eb6c424..48d045e1 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_event.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_event.dart @@ -10,3 +10,15 @@ abstract class ClientSettingsEvent extends Equatable { class ClientSettingsSignOutRequested extends ClientSettingsEvent { const ClientSettingsSignOutRequested(); } + +class ClientSettingsNotificationToggled extends ClientSettingsEvent { + const ClientSettingsNotificationToggled({ + required this.type, + required this.isEnabled, + }); + final String type; + final bool isEnabled; + + @override + List get props => [type, isEnabled]; +} diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_state.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_state.dart index c83bb91f..5af3dd7f 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_state.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/blocs/client_settings_state.dart @@ -1,10 +1,49 @@ part of 'client_settings_bloc.dart'; -abstract class ClientSettingsState extends Equatable { - const ClientSettingsState(); +class ClientSettingsState extends Equatable { + const ClientSettingsState({ + this.isLoading = false, + this.isSignOutSuccess = false, + this.errorMessage, + this.pushEnabled = true, + this.emailEnabled = false, + this.smsEnabled = true, + }); + + final bool isLoading; + final bool isSignOutSuccess; + final String? errorMessage; + final bool pushEnabled; + final bool emailEnabled; + final bool smsEnabled; + + ClientSettingsState copyWith({ + bool? isLoading, + bool? isSignOutSuccess, + String? errorMessage, + bool? pushEnabled, + bool? emailEnabled, + bool? smsEnabled, + }) { + return ClientSettingsState( + isLoading: isLoading ?? this.isLoading, + isSignOutSuccess: isSignOutSuccess ?? this.isSignOutSuccess, + errorMessage: errorMessage, // We reset error on copy + pushEnabled: pushEnabled ?? this.pushEnabled, + emailEnabled: emailEnabled ?? this.emailEnabled, + smsEnabled: smsEnabled ?? this.smsEnabled, + ); + } @override - List get props => []; + List get props => [ + isLoading, + isSignOutSuccess, + errorMessage, + pushEnabled, + emailEnabled, + smsEnabled, + ]; } class ClientSettingsInitial extends ClientSettingsState { @@ -12,18 +51,14 @@ class ClientSettingsInitial extends ClientSettingsState { } class ClientSettingsLoading extends ClientSettingsState { - const ClientSettingsLoading(); + const ClientSettingsLoading({super.pushEnabled, super.emailEnabled, super.smsEnabled}) : super(isLoading: true); } class ClientSettingsSignOutSuccess extends ClientSettingsState { - const ClientSettingsSignOutSuccess(); + const ClientSettingsSignOutSuccess() : super(isSignOutSuccess: true); } class ClientSettingsError extends ClientSettingsState { - final String message; - - const ClientSettingsError(this.message); - - @override - List get props => [message]; + const ClientSettingsError(String message) : super(errorMessage: message); + String get message => errorMessage!; } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index edf6b8e3..508b5396 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -42,6 +42,7 @@ class ClientSettingsPage extends StatelessWidget { } }, child: const Scaffold( + backgroundColor: UiColors.bgMenu, body: CustomScrollView( slivers: [ SettingsProfileHeader(), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart new file mode 100644 index 00000000..a73d6847 --- /dev/null +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/edit_profile_page.dart @@ -0,0 +1,148 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; + +class EditProfilePage extends StatefulWidget { + const EditProfilePage({super.key}); + + @override + State createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + late TextEditingController _emailController; + late TextEditingController _phoneController; + + @override + void initState() { + super.initState(); + // Simulate current data + _firstNameController = TextEditingController(text: 'John'); + _lastNameController = TextEditingController(text: 'Smith'); + _emailController = TextEditingController(text: 'john@smith.com'); + _phoneController = TextEditingController(text: '+1 (555) 123-4567'); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.t.client_settings.edit_profile.title), + elevation: 0, + backgroundColor: UiColors.white, + foregroundColor: UiColors.primary, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 50, + backgroundColor: UiColors.bgSecondary, + child: const Icon(UiIcons.user, size: 40, color: UiColors.primary), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.edit, size: 16, color: UiColors.white), + ), + ), + ], + ), + ), + const SizedBox(height: UiConstants.space8), + + Text( + context.t.client_settings.edit_profile.first_name, + style: UiTypography.footnote2b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + UiTextField( + controller: _firstNameController, + hintText: 'First Name', + validator: (String? val) => (val?.isEmpty ?? true) ? 'Required' : null, + ), + const SizedBox(height: UiConstants.space4), + + Text( + context.t.client_settings.edit_profile.last_name, + style: UiTypography.footnote2b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + UiTextField( + controller: _lastNameController, + hintText: 'Last Name', + validator: (String? val) => (val?.isEmpty ?? true) ? 'Required' : null, + ), + const SizedBox(height: UiConstants.space4), + + Text( + context.t.client_settings.edit_profile.email, + style: UiTypography.footnote2b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + UiTextField( + controller: _emailController, + hintText: 'Email', + keyboardType: TextInputType.emailAddress, + validator: (String? val) => (val?.isEmpty ?? true) ? 'Required' : null, + ), + const SizedBox(height: UiConstants.space4), + + Text( + context.t.client_settings.edit_profile.phone, + style: UiTypography.footnote2b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + UiTextField( + controller: _phoneController, + hintText: 'Phone', + keyboardType: TextInputType.phone, + ), + const SizedBox(height: UiConstants.space10), + + UiButton.primary( + text: context.t.client_settings.edit_profile.save_button, + fullWidth: true, + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + UiSnackbar.show( + context, + message: context.t.client_settings.edit_profile.success_message, + type: UiSnackbarType.success, + ); + Navigator.pop(context); + } + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 5f275b01..0950c573 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; + import '../../blocs/client_settings_bloc.dart'; /// A widget that displays the primary actions for the settings page. @@ -14,7 +15,6 @@ class SettingsActions extends StatelessWidget { @override /// Builds the settings actions UI. Widget build(BuildContext context) { - // Get the translations for the client settings profile. final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; @@ -24,38 +24,63 @@ class SettingsActions extends StatelessWidget { delegate: SliverChildListDelegate([ const SizedBox(height: UiConstants.space5), - /// TODO: FEATURE_NOT_YET_IMPLEMENTED - // Edit profile is not yet implemented - - // Hubs button + // Edit Profile button (Yellow) UiButton.primary( - text: labels.hubs, - onPressed: () => Modular.to.toClientHubs(), + text: labels.edit_profile, + fullWidth: true, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), + onPressed: () => Modular.to.toClientEditProfile(), ), const SizedBox(height: UiConstants.space4), - // Log out button + // Hubs button (Yellow) + UiButton.primary( + text: labels.hubs, + fullWidth: true, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), + onPressed: () => Modular.to.toClientHubs(), + ), + const SizedBox(height: UiConstants.space5), + + // Quick Links card + _QuickLinksCard(labels: labels), + const SizedBox(height: UiConstants.space5), + + // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { return UiButton.secondary( text: labels.log_out, + fullWidth: true, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: UiColors.black), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), onPressed: state is ClientSettingsLoading ? null : () => _showSignOutDialog(context), ); }, ), + const SizedBox(height: UiConstants.space8), ]), ), ); } - - /// Handles the sign-out button click event. - void _onSignoutClicked(BuildContext context) { - ReadContext( - context, - ).read().add(const ClientSettingsSignOutRequested()); - } /// Shows a confirmation dialog for signing out. Future _showSignOutDialog(BuildContext context) { @@ -74,13 +99,10 @@ class SettingsActions extends StatelessWidget { style: UiTypography.body2r.textSecondary, ), actions: [ - // Log out button UiButton.secondary( text: t.client_settings.profile.log_out, onPressed: () => _onSignoutClicked(context), ), - - // Cancel button UiButton.secondary( text: t.common.cancel, onPressed: () => Modular.to.pop(), @@ -89,4 +111,197 @@ class SettingsActions extends StatelessWidget { ), ); } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext( + context, + ).read().add(const ClientSettingsSignOutRequested()); + } +} + +/// Quick Links card โ€” inline here since it's always part of SettingsActions ordering. +class _QuickLinksCard extends StatelessWidget { + const _QuickLinksCard({required this.labels}); + final TranslationsClientSettingsProfileEn labels; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide(color: UiColors.border), + ), + color: UiColors.white, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + labels.quick_links, + style: UiTypography.footnote1b.textPrimary, + ), + const SizedBox(height: UiConstants.space3), + _QuickLinkItem( + icon: UiIcons.nfc, + title: labels.clock_in_hubs, + onTap: () => Modular.to.toClientHubs(), + ), + _QuickLinkItem( + icon: UiIcons.file, + title: labels.billing_payments, + onTap: () => Modular.to.toClientBilling(), + ), + ], + ), + ), + ); + } +} + +/// A single quick link row item. +class _QuickLinkItem extends StatelessWidget { + const _QuickLinkItem({ + required this.icon, + required this.title, + required this.onTap, + }); + final IconData icon; + final String title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: UiConstants.radiusMd, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + horizontal: UiConstants.space2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Text(title, style: UiTypography.footnote1m.textPrimary), + ], + ), + const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconThird, + ), + ], + ), + ), + ); + } +} + +class _NotificationsSettingsCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide(color: UiColors.border), + ), + color: UiColors.white, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_settings.preferences.title, + style: UiTypography.footnote1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + _NotificationToggle( + icon: UiIcons.bell, + title: context.t.client_settings.preferences.push, + value: state.pushEnabled, + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'push', + isEnabled: val, + ), + ), + ), + _NotificationToggle( + icon: UiIcons.mail, + title: context.t.client_settings.preferences.email, + value: state.emailEnabled, + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'email', + isEnabled: val, + ), + ), + ), + _NotificationToggle( + icon: UiIcons.phone, + title: context.t.client_settings.preferences.sms, + value: state.smsEnabled, + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'sms', + isEnabled: val, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _NotificationToggle extends StatelessWidget { + final IconData icon; + final String title; + final bool value; + final ValueChanged onChanged; + + const _NotificationToggle({ + required this.icon, + required this.title, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Text(title, style: UiTypography.footnote1m.textPrimary), + ], + ), + Switch.adaptive( + value: value, + activeColor: UiColors.primary, + onChanged: onChanged, + ), + ], + ); + } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart new file mode 100644 index 00000000..ea359254 --- /dev/null +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -0,0 +1,87 @@ +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/client_settings_bloc.dart'; + +/// A widget that displays the log out button. +class SettingsLogout extends StatelessWidget { + /// Creates a [SettingsLogout]. + const SettingsLogout({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientSettingsProfileEn labels = + t.client_settings.profile; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + sliver: SliverToBoxAdapter( + child: BlocBuilder( + builder: (BuildContext context, ClientSettingsState state) { + return UiButton.primary( + text: labels.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: state is ClientSettingsLoading + ? null + : () => _showSignOutDialog(context), + ); + }, + ), + ), + ); + } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext( + context, + ).read().add(const ClientSettingsSignOutRequested()); + } + + /// Shows a confirmation dialog for signing out. + Future _showSignOutDialog(BuildContext context) { + return showDialog( + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + backgroundColor: UiColors.bgPopup, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), + title: Text( + t.client_settings.profile.log_out, + style: UiTypography.headline3m.textPrimary, + ), + content: Text( + t.client_settings.profile.log_out_confirmation, + style: UiTypography.body2r.textSecondary, + ), + actions: [ + // Log out button + UiButton.primary( + text: t.client_settings.profile.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: () => _onSignoutClicked(context), + ), + + // Cancel button + UiButton.secondary( + text: t.common.cancel, + onPressed: () => Modular.to.pop(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index b9ddd93e..dd746425 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -11,9 +11,9 @@ class SettingsProfileHeader extends StatelessWidget { const SettingsProfileHeader({super.key}); @override - /// Builds the profile header UI. Widget build(BuildContext context) { - final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; + final TranslationsClientSettingsProfileEn labels = + t.client_settings.profile; final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final String businessName = session?.business?.businessName ?? 'Your Company'; @@ -23,78 +23,108 @@ class SettingsProfileHeader extends StatelessWidget { ? businessName.trim()[0].toUpperCase() : 'C'; - return SliverAppBar( - backgroundColor: UiColors.bgSecondary, - expandedHeight: 140, - pinned: true, - elevation: 0, - shape: const Border(bottom: BorderSide(color: UiColors.border, width: 1)), - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.toClientHome(), - ), - flexibleSpace: FlexibleSpaceBar( - background: Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8), - margin: const EdgeInsets.only(top: UiConstants.space24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: UiColors.border, width: 2), - color: UiColors.white, + return SliverToBoxAdapter( + child: Container( + width: double.infinity, + padding: const EdgeInsets.only(bottom: 36), + decoration: const BoxDecoration(color: UiColors.primary), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // โ”€โ”€ Top bar: back arrow + centered title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, ), - child: CircleAvatar( - backgroundColor: UiColors.primary.withValues(alpha: 0.1), - backgroundImage: - photoUrl != null && photoUrl.isNotEmpty - ? NetworkImage(photoUrl) - : null, - child: - photoUrl != null && photoUrl.isNotEmpty - ? null - : Text( - avatarLetter, - style: UiTypography.headline1m.copyWith( - color: UiColors.primary, - ), - ), + child: Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 22, + ), + ), + ), + Text( + labels.title, + style: UiTypography.body1b.copyWith( + color: UiColors.white, + fontSize: 18, + ), + ), + ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(businessName, style: UiTypography.body1b.textPrimary), - const SizedBox(height: UiConstants.space1), - Row( - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space1, - children: [ - Icon( - UiIcons.mail, - size: 14, - color: UiColors.textSecondary, + ), + + const SizedBox(height: UiConstants.space6), + + // โ”€โ”€ Avatar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: UiColors.white, + border: Border.all( + color: UiColors.white.withValues(alpha: 0.6), + width: 3, + ), + ), + child: ClipOval( + child: photoUrl != null && photoUrl.isNotEmpty + ? Image.network(photoUrl, fit: BoxFit.cover) + : Center( + child: Text( + avatarLetter, + style: UiTypography.headline1m.copyWith( + color: UiColors.primary, + fontSize: 32, + ), + ), ), - Text( - email, - style: UiTypography.footnote1r.textSecondary, - ), - ], + ), + ), + + const SizedBox(height: UiConstants.space4), + + // โ”€โ”€ Business Name โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Text( + businessName, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + + const SizedBox(height: UiConstants.space2), + + // โ”€โ”€ Email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + UiIcons.mail, + size: 14, + color: UiColors.white.withValues(alpha: 0.75), + ), + const SizedBox(width: 6), + Text( + email, + style: UiTypography.footnote1r.copyWith( + color: UiColors.white.withValues(alpha: 0.75), ), - ], - ), - ], - ), + ), + ], + ), + ], ), ), - title: Text(labels.title, style: UiTypography.body1b.textPrimary), ); } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_quick_links.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_quick_links.dart index e9b0bcae..1a97d387 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_quick_links.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_quick_links.dart @@ -56,6 +56,13 @@ class SettingsQuickLinks extends StatelessWidget { /// Internal widget for a single quick link item. class _QuickLinkItem extends StatelessWidget { + + /// Creates a [_QuickLinkItem]. + const _QuickLinkItem({ + required this.icon, + required this.title, + required this.onTap, + }); /// The icon to display. final IconData icon; @@ -65,13 +72,6 @@ class _QuickLinkItem extends StatelessWidget { /// Callback when the link is tapped. final VoidCallback onTap; - /// Creates a [_QuickLinkItem]. - const _QuickLinkItem({ - required this.icon, - required this.title, - required this.onTap, - }); - @override /// Builds the quick link item UI. Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart deleted file mode 100644 index d2f972e8..00000000 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ /dev/null @@ -1,255 +0,0 @@ -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:intl/intl.dart'; - -import 'package:core_localization/core_localization.dart'; -import 'package:krow_core/core.dart'; -import '../blocs/view_orders_cubit.dart'; -import '../blocs/view_orders_state.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../widgets/view_order_card.dart'; -import '../widgets/view_orders_header.dart'; - -/// The main page for viewing client orders. -/// -/// This page follows the KROW Clean Architecture by: -/// - Being a [StatelessWidget]. -/// - Using [ViewOrdersCubit] for state management. -/// - Adhering to the project's Design System. -class ViewOrdersPage extends StatelessWidget { - /// Creates a [ViewOrdersPage]. - const ViewOrdersPage({super.key, this.initialDate}); - - /// The initial date to display orders for. - final DateTime? initialDate; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (BuildContext context) => Modular.get(), - child: ViewOrdersView(initialDate: initialDate), - ); - } -} - -/// The internal view implementation for [ViewOrdersPage]. -class ViewOrdersView extends StatefulWidget { - /// Creates a [ViewOrdersView]. - const ViewOrdersView({super.key, this.initialDate}); - - /// The initial date to display orders for. - final DateTime? initialDate; - - @override - State createState() => _ViewOrdersViewState(); -} - -class _ViewOrdersViewState extends State { - bool _didInitialJump = false; - ViewOrdersCubit? _cubit; - - @override - void initState() { - super.initState(); - // Force initialization of cubit immediately - _cubit = BlocProvider.of(context, listen: false); - - if (widget.initialDate != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (_didInitialJump) return; - _didInitialJump = true; - _cubit?.jumpToDate(widget.initialDate!); - }); - } - } - - @override - Widget build(BuildContext context) { - return BlocConsumer( - listener: (BuildContext context, ViewOrdersState state) { - if (state.status == ViewOrdersStatus.failure && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, ViewOrdersState state) { - final List calendarDays = state.calendarDays; - final List filteredOrders = state.filteredOrders; - - // Header Colors logic from prototype - String sectionTitle = ''; - Color dotColor = UiColors.transparent; - - if (state.filterTab == 'all') { - sectionTitle = t.client_view_orders.tabs.up_next; - dotColor = UiColors.primary; - } else if (state.filterTab == 'active') { - sectionTitle = t.client_view_orders.tabs.active; - dotColor = UiColors.textWarning; - } else if (state.filterTab == 'completed') { - sectionTitle = t.client_view_orders.tabs.completed; - dotColor = - UiColors.primary; // Reverting to primary blue for consistency - } - - return Scaffold( - body: SafeArea( - child: Column( - children: [ - // Header + Filter + Calendar (Sticky behavior) - ViewOrdersHeader( - state: state, - calendarDays: calendarDays, - ), - - // Content List - Expanded( - child: state.status == ViewOrdersStatus.failure - ? _buildErrorState(context: context, state: state) - : filteredOrders.isEmpty - ? _buildEmptyState(context: context, state: state) - : ListView( - padding: const EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space4, - UiConstants.space5, - 100, - ), - children: [ - if (filteredOrders.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox( - width: UiConstants.space2, - ), - Text( - sectionTitle.toUpperCase(), - style: UiTypography.titleUppercase2m - .copyWith( - color: UiColors.textPrimary, - ), - ), - const SizedBox( - width: UiConstants.space1, - ), - Text( - '(${filteredOrders.length})', - style: UiTypography.footnote1r - .copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - ), - ...filteredOrders.map( - (OrderItem order) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: ViewOrderCard(order: order), - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ); - } - - /// Builds the empty state view. - Widget _buildEmptyState({ - required BuildContext context, - required ViewOrdersState state, - }) { - final String dateStr = state.selectedDate != null - ? _formatDateHeader(state.selectedDate!) - : 'this date'; - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(UiIcons.calendar, size: 48, color: UiColors.iconInactive), - const SizedBox(height: UiConstants.space3), - Text( - t.client_view_orders.no_orders(date: dateStr), - style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), - ), - const SizedBox(height: UiConstants.space4), - UiButton.primary( - text: t.client_view_orders.post_order, - leadingIcon: UiIcons.add, - onPressed: () => Modular.to.toCreateOrder(), - ), - ], - ), - ); - } - - static String _formatDateHeader(DateTime date) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - final DateTime tomorrow = today.add(const Duration(days: 1)); - final DateTime checkDate = DateTime(date.year, date.month, date.day); - - if (checkDate == today) return 'Today'; - if (checkDate == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); - } - - Widget _buildErrorState({ - required BuildContext context, - required ViewOrdersState state, - }) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.error, - size: 48, - color: UiColors.error, - ), - const SizedBox(height: UiConstants.space4), - Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - style: UiTypography.body1m.textError, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space4), - UiButton.secondary( - text: 'Retry', - onPressed: () => BlocProvider.of(context) - .jumpToDate(state.selectedDate ?? DateTime.now()), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 5d204fcf..7b6bc1bc 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -215,14 +215,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { staffRecord = staffResponse.data.staffs.first; } - final String email = user?.email ?? ''; - //TO-DO: create(registration) user and staff account //TO-DO: save user data locally final domain.User domainUser = domain.User( id: firebaseUser.uid, - email: email, - phone: firebaseUser.phoneNumber, + email: user?.email ?? '', + phone: user?.phone, role: user?.role.stringValue ?? 'USER', ); final domain.Staff? domainStaff = staffRecord == null @@ -245,4 +243,5 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } + } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart index 6d6512b5..0155114a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart @@ -6,13 +6,13 @@ import 'package:krow_core/core.dart'; import '../../domain/repositories/place_repository.dart'; class PlaceRepositoryImpl implements PlaceRepository { - final http.Client _client; PlaceRepositoryImpl({http.Client? client}) : _client = client ?? http.Client(); + final http.Client _client; @override Future> searchCities(String query) async { - if (query.isEmpty) return []; + if (query.isEmpty) return []; final Uri uri = Uri.https( 'maps.googleapis.com', @@ -39,7 +39,7 @@ class PlaceRepositoryImpl implements PlaceRepository { } else { // Handle other statuses (OVER_QUERY_LIMIT, REQUEST_DENIED, etc.) // Returning empty list for now to avoid crashing UI, ideally log this. - return []; + return []; } } else { throw Exception('Network Error: ${response.statusCode}'); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart index c2f013d6..d3dd4a65 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -5,9 +5,9 @@ import 'package:firebase_auth/firebase_auth.dart' as auth; import '../../domain/repositories/profile_setup_repository.dart'; class ProfileSetupRepositoryImpl implements ProfileSetupRepository { - final DataConnectService _service; ProfileSetupRepositoryImpl() : _service = DataConnectService.instance; + final DataConnectService _service; @override Future submitProfile({ diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/sign_in_with_phone_arguments.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/sign_in_with_phone_arguments.dart index 2811adb5..0ecfce5a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/sign_in_with_phone_arguments.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/sign_in_with_phone_arguments.dart @@ -4,13 +4,13 @@ import 'package:krow_core/core.dart'; /// /// Encapsulates the phone number needed to initiate the sign-in process. class SignInWithPhoneArguments extends UseCaseArgument { - /// The phone number to be used for sign-in or sign-up. - final String phoneNumber; /// Creates a [SignInWithPhoneArguments] instance. /// /// The [phoneNumber] is required. const SignInWithPhoneArguments({required this.phoneNumber}); + /// The phone number to be used for sign-in or sign-up. + final String phoneNumber; @override List get props => [phoneNumber]; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart index ac7cd4ef..7b7eefe6 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart @@ -6,14 +6,6 @@ import '../ui_entities/auth_mode.dart'; /// Encapsulates the verification ID and the SMS code needed to verify /// a phone number during the authentication process. class VerifyOtpArguments extends UseCaseArgument { - /// The unique identifier received after requesting an OTP. - final String verificationId; - - /// The one-time password (OTP) sent to the user's phone. - final String smsCode; - - /// The authentication mode (login or signup). - final AuthMode mode; /// Creates a [VerifyOtpArguments] instance. /// @@ -23,6 +15,14 @@ class VerifyOtpArguments extends UseCaseArgument { required this.smsCode, required this.mode, }); + /// The unique identifier received after requesting an OTP. + final String verificationId; + + /// The one-time password (OTP) sent to the user's phone. + final String smsCode; + + /// The authentication mode (login or signup). + final AuthMode mode; @override List get props => [verificationId, smsCode, mode]; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 0ee6fc5a..bbdc1e63 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,4 +20,5 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); + } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart index 8b99f0f9..3c5b17c7 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart @@ -1,4 +1,3 @@ -import 'package:krow_domain/krow_domain.dart'; abstract class ProfileSetupRepository { Future submitProfile({ diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart index def8c3ca..0648c16c 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart @@ -1,9 +1,9 @@ import '../repositories/place_repository.dart'; class SearchCitiesUseCase { - final PlaceRepository _repository; SearchCitiesUseCase(this._repository); + final PlaceRepository _repository; Future> call(String query) { return _repository.searchCities(query); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart index ed2878e4..7331127b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart @@ -7,12 +7,12 @@ import '../repositories/auth_repository_interface.dart'; /// This use case delegates the sign-in logic to the [AuthRepositoryInterface]. class SignInWithPhoneUseCase implements UseCase { - final AuthRepositoryInterface _repository; /// Creates a [SignInWithPhoneUseCase]. /// /// Requires an [AuthRepositoryInterface] to interact with the authentication data source. SignInWithPhoneUseCase(this._repository); + final AuthRepositoryInterface _repository; @override Future call(SignInWithPhoneArguments arguments) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart index b69f5fe6..78d39066 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart @@ -1,9 +1,9 @@ import '../repositories/profile_setup_repository.dart'; class SubmitProfileSetup { - final ProfileSetupRepository repository; SubmitProfileSetup(this.repository); + final ProfileSetupRepository repository; Future call({ required String fullName, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart index 0c359968..33b8eb70 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart @@ -7,12 +7,12 @@ import '../repositories/auth_repository_interface.dart'; /// /// This use case delegates the OTP verification logic to the [AuthRepositoryInterface]. class VerifyOtpUseCase implements UseCase { - final AuthRepositoryInterface _repository; /// Creates a [VerifyOtpUseCase]. /// /// Requires an [AuthRepositoryInterface] to interact with the authentication data source. VerifyOtpUseCase(this._repository); + final AuthRepositoryInterface _repository; @override Future call(VerifyOtpArguments arguments) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart index cf392d1b..4b43622e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart @@ -14,16 +14,6 @@ import 'auth_state.dart'; class AuthBloc extends Bloc with BlocErrorHandler implements Disposable { - /// The use case for signing in with a phone number. - final SignInWithPhoneUseCase _signInUseCase; - - /// The use case for verifying an OTP. - final VerifyOtpUseCase _verifyOtpUseCase; - int _requestToken = 0; - DateTime? _lastCodeRequestAt; - DateTime? _cooldownUntil; - static const Duration _resendCooldown = Duration(seconds: 31); - Timer? _cooldownTimer; /// Creates an [AuthBloc]. AuthBloc({ @@ -40,6 +30,16 @@ class AuthBloc extends Bloc on(_onResetRequested); on(_onCooldownTicked); } + /// The use case for signing in with a phone number. + final SignInWithPhoneUseCase _signInUseCase; + + /// The use case for verifying an OTP. + final VerifyOtpUseCase _verifyOtpUseCase; + int _requestToken = 0; + DateTime? _lastCodeRequestAt; + DateTime? _cooldownUntil; + static const Duration _resendCooldown = Duration(seconds: 31); + Timer? _cooldownTimer; /// Clears any authentication error from the state. void _onErrorCleared(AuthErrorCleared event, Emitter emit) { @@ -111,7 +111,7 @@ class AuthBloc extends Bloc ); await handleError( - emit: emit, + emit: emit.call, action: () async { final String? verificationId = await _signInUseCase( SignInWithPhoneArguments( @@ -193,7 +193,7 @@ class AuthBloc extends Bloc ) async { emit(state.copyWith(status: AuthStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final User? user = await _verifyOtpUseCase( VerifyOtpArguments( diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart index cc9a9bea..f150c6f0 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart @@ -10,14 +10,14 @@ abstract class AuthEvent extends Equatable { /// Event for requesting a sign-in with a phone number. class AuthSignInRequested extends AuthEvent { + + const AuthSignInRequested({this.phoneNumber, required this.mode}); /// The phone number provided by the user. final String? phoneNumber; /// The authentication mode (login or signup). final AuthMode mode; - const AuthSignInRequested({this.phoneNumber, required this.mode}); - @override List get props => [phoneNumber, mode]; } @@ -27,6 +27,12 @@ class AuthSignInRequested extends AuthEvent { /// This event is dispatched after the user has received an OTP and /// submits it for verification. class AuthOtpSubmitted extends AuthEvent { + + const AuthOtpSubmitted({ + required this.verificationId, + required this.smsCode, + required this.mode, + }); /// The verification ID received after the phone number submission. final String verificationId; @@ -36,12 +42,6 @@ class AuthOtpSubmitted extends AuthEvent { /// The authentication mode (login or signup). final AuthMode mode; - const AuthOtpSubmitted({ - required this.verificationId, - required this.smsCode, - required this.mode, - }); - @override List get props => [verificationId, smsCode, mode]; } @@ -51,10 +51,10 @@ class AuthErrorCleared extends AuthEvent {} /// Event for resetting the authentication flow back to initial. class AuthResetRequested extends AuthEvent { - /// The authentication mode (login or signup). - final AuthMode mode; const AuthResetRequested({required this.mode}); + /// The authentication mode (login or signup). + final AuthMode mode; @override List get props => [mode]; @@ -62,9 +62,9 @@ class AuthResetRequested extends AuthEvent { /// Event for ticking down the resend cooldown. class AuthCooldownTicked extends AuthEvent { - final int secondsRemaining; const AuthCooldownTicked(this.secondsRemaining); + final int secondsRemaining; @override List get props => [secondsRemaining]; @@ -72,10 +72,10 @@ class AuthCooldownTicked extends AuthEvent { /// Event for updating the current draft OTP in the state. class AuthOtpUpdated extends AuthEvent { - /// The current draft OTP. - final String otp; const AuthOtpUpdated(this.otp); + /// The current draft OTP. + final String otp; @override List get props => [otp]; @@ -83,10 +83,10 @@ class AuthOtpUpdated extends AuthEvent { /// Event for updating the current draft phone number in the state. class AuthPhoneUpdated extends AuthEvent { - /// The current draft phone number. - final String phoneNumber; const AuthPhoneUpdated(this.phoneNumber); + /// The current draft phone number. + final String phoneNumber; @override List get props => [phoneNumber]; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart index eaa6f1f2..849f329a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart @@ -22,6 +22,17 @@ enum AuthStatus { /// A unified state class for the authentication process. class AuthState extends Equatable { + + const AuthState({ + this.status = AuthStatus.initial, + this.verificationId, + this.mode = AuthMode.login, + this.otp = '', + this.phoneNumber = '', + this.errorMessage, + this.cooldownSecondsRemaining = 0, + this.user, + }); /// The current status of the authentication flow. final AuthStatus status; @@ -46,17 +57,6 @@ class AuthState extends Equatable { /// The authenticated user's data (available when status is [AuthStatus.authenticated]). final User? user; - const AuthState({ - this.status = AuthStatus.initial, - this.verificationId, - this.mode = AuthMode.login, - this.otp = '', - this.phoneNumber = '', - this.errorMessage, - this.cooldownSecondsRemaining = 0, - this.user, - }); - @override List get props => [ status, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart index 67a04394..2b645824 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart @@ -89,7 +89,7 @@ class ProfileSetupBloc extends Bloc emit(state.copyWith(status: ProfileSetupStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { await _submitProfileSetup( fullName: state.fullName, @@ -114,18 +114,18 @@ class ProfileSetupBloc extends Bloc Emitter emit, ) async { if (event.query.isEmpty) { - emit(state.copyWith(locationSuggestions: [])); + emit(state.copyWith(locationSuggestions: [])); return; } // For search, we might want to handle errors silently or distinctively // Using simple try-catch here as it's a search-as-you-type feature where error dialogs are intrusive try { - final results = await _searchCities(event.query); + final List results = await _searchCities(event.query); emit(state.copyWith(locationSuggestions: results)); } catch (e) { // Quietly fail or clear - emit(state.copyWith(locationSuggestions: [])); + emit(state.copyWith(locationSuggestions: [])); } } @@ -133,7 +133,7 @@ class ProfileSetupBloc extends Bloc ProfileSetupClearLocationSuggestions event, Emitter emit, ) { - emit(state.copyWith(locationSuggestions: [])); + emit(state.copyWith(locationSuggestions: [])); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_event.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_event.dart index b628f342..89773570 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_event.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_event.dart @@ -10,11 +10,11 @@ abstract class ProfileSetupEvent extends Equatable { /// Event triggered when the full name changes. class ProfileSetupFullNameChanged extends ProfileSetupEvent { - /// The new full name value. - final String fullName; /// Creates a [ProfileSetupFullNameChanged] event. const ProfileSetupFullNameChanged(this.fullName); + /// The new full name value. + final String fullName; @override List get props => [fullName]; @@ -22,11 +22,11 @@ class ProfileSetupFullNameChanged extends ProfileSetupEvent { /// Event triggered when the bio changes. class ProfileSetupBioChanged extends ProfileSetupEvent { - /// The new bio value. - final String bio; /// Creates a [ProfileSetupBioChanged] event. const ProfileSetupBioChanged(this.bio); + /// The new bio value. + final String bio; @override List get props => [bio]; @@ -34,11 +34,11 @@ class ProfileSetupBioChanged extends ProfileSetupEvent { /// Event triggered when the preferred locations change. class ProfileSetupLocationsChanged extends ProfileSetupEvent { - /// The new list of locations. - final List locations; /// Creates a [ProfileSetupLocationsChanged] event. const ProfileSetupLocationsChanged(this.locations); + /// The new list of locations. + final List locations; @override List get props => [locations]; @@ -46,11 +46,11 @@ class ProfileSetupLocationsChanged extends ProfileSetupEvent { /// Event triggered when the max distance changes. class ProfileSetupDistanceChanged extends ProfileSetupEvent { - /// The new max distance value in miles. - final double distance; /// Creates a [ProfileSetupDistanceChanged] event. const ProfileSetupDistanceChanged(this.distance); + /// The new max distance value in miles. + final double distance; @override List get props => [distance]; @@ -58,11 +58,11 @@ class ProfileSetupDistanceChanged extends ProfileSetupEvent { /// Event triggered when the skills change. class ProfileSetupSkillsChanged extends ProfileSetupEvent { - /// The new list of selected skills. - final List skills; /// Creates a [ProfileSetupSkillsChanged] event. const ProfileSetupSkillsChanged(this.skills); + /// The new list of selected skills. + final List skills; @override List get props => [skills]; @@ -70,11 +70,11 @@ class ProfileSetupSkillsChanged extends ProfileSetupEvent { /// Event triggered when the industries change. class ProfileSetupIndustriesChanged extends ProfileSetupEvent { - /// The new list of selected industries. - final List industries; /// Creates a [ProfileSetupIndustriesChanged] event. const ProfileSetupIndustriesChanged(this.industries); + /// The new list of selected industries. + final List industries; @override List get props => [industries]; @@ -82,11 +82,11 @@ class ProfileSetupIndustriesChanged extends ProfileSetupEvent { /// Event triggered when the location query changes. class ProfileSetupLocationQueryChanged extends ProfileSetupEvent { - /// The search query. - final String query; /// Creates a [ProfileSetupLocationQueryChanged] event. const ProfileSetupLocationQueryChanged(this.query); + /// The search query. + final String query; @override List get props => [query]; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_state.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_state.dart index b007757b..d520843f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_state.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_state.dart @@ -5,6 +5,19 @@ enum ProfileSetupStatus { initial, loading, success, failure } /// State for the ProfileSetupBloc. class ProfileSetupState extends Equatable { + + /// Creates a [ProfileSetupState] instance. + const ProfileSetupState({ + this.fullName = '', + this.bio = '', + this.preferredLocations = const [], + this.maxDistanceMiles = 25, + this.skills = const [], + this.industries = const [], + this.status = ProfileSetupStatus.initial, + this.errorMessage, + this.locationSuggestions = const [], + }); /// The user's full name. final String fullName; @@ -32,19 +45,6 @@ class ProfileSetupState extends Equatable { /// List of location suggestions from the API. final List locationSuggestions; - /// Creates a [ProfileSetupState] instance. - const ProfileSetupState({ - this.fullName = '', - this.bio = '', - this.preferredLocations = const [], - this.maxDistanceMiles = 25, - this.skills = const [], - this.industries = const [], - this.status = ProfileSetupStatus.initial, - this.errorMessage, - this.locationSuggestions = const [], - }); - /// Creates a copy of the current state with updated values. ProfileSetupState copyWith({ String? fullName, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 109761aa..8060a72f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -17,11 +17,11 @@ import '../widgets/phone_verification_page/phone_input.dart'; /// This page coordinates the authentication flow by switching between /// [PhoneInput] and [OtpVerification] based on the current [AuthState]. class PhoneVerificationPage extends StatefulWidget { - /// The authentication mode (login or signup). - final AuthMode mode; /// Creates a [PhoneVerificationPage]. const PhoneVerificationPage({super.key, required this.mode}); + /// The authentication mode (login or signup). + final AuthMode mode; @override State createState() => _PhoneVerificationPageState(); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart index 3ff2fe24..d7707c58 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart @@ -157,9 +157,9 @@ class _ProfileSetupPageState extends State { ), ), child: isCreatingProfile - ? ElevatedButton( + ? const ElevatedButton( onPressed: null, - child: const SizedBox( + child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/common/section_title_subtitle.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/common/section_title_subtitle.dart index 0b1beba1..d6d3f31d 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/common/section_title_subtitle.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/common/section_title_subtitle.dart @@ -3,17 +3,17 @@ import 'package:flutter/material.dart'; /// A widget for displaying a section title and subtitle class SectionTitleSubtitle extends StatelessWidget { - /// The title of the section - final String title; - - /// The subtitle of the section - final String subtitle; const SectionTitleSubtitle({ super.key, required this.title, required this.subtitle, }); + /// The title of the section + final String title; + + /// The subtitle of the section + final String subtitle; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart index 7e7ead4b..eb809140 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart @@ -3,14 +3,14 @@ import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; class GetStartedActions extends StatelessWidget { - final VoidCallback onSignUpPressed; - final VoidCallback onLoginPressed; const GetStartedActions({ super.key, required this.onSignUpPressed, required this.onLoginPressed, }); + final VoidCallback onSignUpPressed; + final VoidCallback onLoginPressed; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart index 7cf03c16..42b12e15 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart @@ -15,7 +15,7 @@ class _GetStartedBackgroundState extends State { Widget build(BuildContext context) { return Container( child: Column( - children: [ + children: [ const SizedBox(height: UiConstants.space8), // Logo Image.asset( @@ -35,7 +35,7 @@ class _GetStartedBackgroundState extends State { child: ClipOval( child: Stack( fit: StackFit.expand, - children: [ + children: [ // Layer 1: The Fallback Logo (Always visible until image loads) Padding( padding: const EdgeInsets.all(UiConstants.space12), @@ -47,7 +47,7 @@ class _GetStartedBackgroundState extends State { Image.network( 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) return child; // Only animate opacity if we have a frame return AnimatedOpacity( @@ -56,12 +56,12 @@ class _GetStartedBackgroundState extends State { child: child, ); }, - loadingBuilder: (context, child, loadingProgress) { + loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { // While loading, show nothing (transparent) so layer 1 shows if (loadingProgress == null) return child; return const SizedBox.shrink(); }, - errorBuilder: (context, error, stackTrace) { + errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { // On error, show nothing (transparent) so layer 1 shows // Also schedule a state update to prevent retries if needed WidgetsBinding.instance.addPostFrameCallback((_) { @@ -83,7 +83,7 @@ class _GetStartedBackgroundState extends State { // Pagination dots (Visual only) Row( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Container( width: UiConstants.space6, height: UiConstants.space2, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification.dart index 4df7987e..2d6ea138 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification.dart @@ -8,6 +8,15 @@ import 'otp_verification/otp_verification_header.dart'; /// A widget that displays the OTP verification UI. class OtpVerification extends StatelessWidget { + + /// Creates an [OtpVerification]. + const OtpVerification({ + super.key, + required this.state, + required this.onOtpSubmitted, + required this.onResend, + required this.onContinue, + }); /// The current state of the authentication process. final AuthState state; @@ -20,15 +29,6 @@ class OtpVerification extends StatelessWidget { /// Callback for the "Continue" action. final VoidCallback onContinue; - /// Creates an [OtpVerification]. - const OtpVerification({ - super.key, - required this.state, - required this.onOtpSubmitted, - required this.onResend, - required this.onContinue, - }); - @override Widget build(BuildContext context) { return Column( diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart index ca756ad0..05fa1f30 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,11 +12,6 @@ import '../../../blocs/auth_bloc.dart'; /// This widget handles its own internal [TextEditingController]s and focus nodes. /// It dispatches [AuthOtpUpdated] to the [AuthBloc] on every change. class OtpInputField extends StatefulWidget { - /// Callback for when the OTP code is fully entered (6 digits). - final ValueChanged onCompleted; - - /// The error message to display, if any. - final String error; /// Creates an [OtpInputField]. const OtpInputField({ @@ -23,6 +19,11 @@ class OtpInputField extends StatefulWidget { required this.onCompleted, required this.error, }); + /// Callback for when the OTP code is fully entered (6 digits). + final ValueChanged onCompleted; + + /// The error message to display, if any. + final String error; @override State createState() => _OtpInputFieldState(); @@ -129,3 +130,4 @@ class _OtpInputFieldState extends State { ); } } + diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_resend_section.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_resend_section.dart index 41793f03..85f0c887 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_resend_section.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_resend_section.dart @@ -1,14 +1,10 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; /// A widget that handles the OTP resend logic and countdown timer. class OtpResendSection extends StatefulWidget { - /// Callback for when the resend link is pressed. - final VoidCallback onResend; - - /// Whether an error is currently displayed. (Used for layout tweaks in the original code) - final bool hasError; /// Creates an [OtpResendSection]. const OtpResendSection({ @@ -16,6 +12,11 @@ class OtpResendSection extends StatefulWidget { required this.onResend, this.hasError = false, }); + /// Callback for when the resend link is pressed. + final VoidCallback onResend; + + /// Whether an error is currently displayed. (Used for layout tweaks in the original code) + final bool hasError; @override State createState() => _OtpResendSectionState(); @@ -73,3 +74,4 @@ class _OtpResendSectionState extends State { ); } } + diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart index 750a0cff..360d8b06 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart @@ -6,14 +6,6 @@ import '../../common/auth_trouble_link.dart'; /// A widget that displays the primary action button and trouble link for OTP verification. class OtpVerificationActions extends StatelessWidget { - /// Whether the verification process is currently loading. - final bool isLoading; - - /// Whether the submit button should be enabled. - final bool canSubmit; - - /// Callback for when the Continue button is pressed. - final VoidCallback? onContinue; /// Creates an [OtpVerificationActions]. const OtpVerificationActions({ @@ -22,6 +14,14 @@ class OtpVerificationActions extends StatelessWidget { required this.canSubmit, this.onContinue, }); + /// Whether the verification process is currently loading. + final bool isLoading; + + /// Whether the submit button should be enabled. + final bool canSubmit; + + /// Callback for when the Continue button is pressed. + final VoidCallback? onContinue; @override Widget build(BuildContext context) { @@ -36,9 +36,9 @@ class OtpVerificationActions extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ isLoading - ? ElevatedButton( + ? const ElevatedButton( onPressed: null, - child: const SizedBox( + child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_header.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_header.dart index d3bcfa5e..5eb03e54 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_header.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_header.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; /// A widget that displays the title and subtitle for the OTP Verification page. class OtpVerificationHeader extends StatelessWidget { - /// The phone number to which the code was sent. - final String phoneNumber; /// Creates an [OtpVerificationHeader]. const OtpVerificationHeader({super.key, required this.phoneNumber}); + /// The phone number to which the code was sent. + final String phoneNumber; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_actions.dart index b9ced284..8b4b6f85 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_actions.dart @@ -5,11 +5,6 @@ import 'package:staff_authentication/src/presentation/widgets/common/auth_troubl /// A widget that displays the primary action button and trouble link for Phone Input. class PhoneInputActions extends StatelessWidget { - /// Whether the sign-in process is currently loading. - final bool isLoading; - - /// Callback for when the Send Code button is pressed. - final VoidCallback? onSendCode; /// Creates a [PhoneInputActions]. const PhoneInputActions({ @@ -17,6 +12,11 @@ class PhoneInputActions extends StatelessWidget { required this.isLoading, this.onSendCode, }); + /// Whether the sign-in process is currently loading. + final bool isLoading; + + /// Callback for when the Send Code button is pressed. + final VoidCallback? onSendCode; @override Widget build(BuildContext context) { @@ -29,9 +29,9 @@ class PhoneInputActions extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ isLoading - ? UiButton.secondary( + ? const UiButton.secondary( onPressed: null, - child: const SizedBox( + child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart index 0ed74eff..256e4f7b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart @@ -2,20 +2,11 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:core_localization/core_localization.dart'; -import 'package:staff_authentication/staff_authentication.dart'; /// A widget that displays the phone number input field with country code. /// /// This widget handles its own [TextEditingController] to manage input. class PhoneInputFormField extends StatefulWidget { - /// The initial value for the phone number. - final String initialValue; - - /// The error message to display, if any. - final String error; - - /// Callback for when the text field value changes. - final ValueChanged onChanged; /// Creates a [PhoneInputFormField]. const PhoneInputFormField({ @@ -24,6 +15,14 @@ class PhoneInputFormField extends StatefulWidget { required this.error, required this.onChanged, }); + /// The initial value for the phone number. + final String initialValue; + + /// The error message to display, if any. + final String error; + + /// Callback for when the text field value changes. + final ValueChanged onChanged; @override State createState() => _PhoneInputFormFieldState(); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart index 93adabd5..0f13491c 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart @@ -5,6 +5,15 @@ import 'package:staff_authentication/src/presentation/widgets/common/section_tit /// A widget for setting up basic profile information (photo, name, bio). class ProfileSetupBasicInfo extends StatelessWidget { + + /// Creates a [ProfileSetupBasicInfo] widget. + const ProfileSetupBasicInfo({ + super.key, + required this.fullName, + required this.bio, + required this.onFullNameChanged, + required this.onBioChanged, + }); /// The user's full name. final String fullName; @@ -17,15 +26,6 @@ class ProfileSetupBasicInfo extends StatelessWidget { /// Callback for when the bio changes. final ValueChanged onBioChanged; - /// Creates a [ProfileSetupBasicInfo] widget. - const ProfileSetupBasicInfo({ - super.key, - required this.fullName, - required this.bio, - required this.onFullNameChanged, - required this.onBioChanged, - }); - @override /// Builds the basic info step UI. Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart index e834dd1e..ef0cd840 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart @@ -6,6 +6,15 @@ import 'package:staff_authentication/src/presentation/widgets/common/section_tit /// A widget for setting up skills and preferred industries. class ProfileSetupExperience extends StatelessWidget { + + /// Creates a [ProfileSetupExperience] widget. + const ProfileSetupExperience({ + super.key, + required this.skills, + required this.industries, + required this.onSkillsChanged, + required this.onIndustriesChanged, + }); /// The list of selected skills. final List skills; @@ -18,15 +27,6 @@ class ProfileSetupExperience extends StatelessWidget { /// Callback for when industries change. final ValueChanged> onIndustriesChanged; - /// Creates a [ProfileSetupExperience] widget. - const ProfileSetupExperience({ - super.key, - required this.skills, - required this.industries, - required this.onSkillsChanged, - required this.onIndustriesChanged, - }); - /// Toggles a skill. void _toggleSkill({required String skill}) { final List updatedList = List.from(skills); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_header.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_header.dart index f4168b7d..af48d092 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_header.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_header.dart @@ -4,14 +4,6 @@ import 'package:flutter/material.dart'; /// A header widget for the profile setup page showing back button and step count. class ProfileSetupHeader extends StatelessWidget { - /// The current step index (0-based). - final int currentStep; - - /// The total number of steps. - final int totalSteps; - - /// Callback when the back button is tapped. - final VoidCallback? onBackTap; /// Creates a [ProfileSetupHeader]. const ProfileSetupHeader({ @@ -20,6 +12,14 @@ class ProfileSetupHeader extends StatelessWidget { required this.totalSteps, this.onBackTap, }); + /// The current step index (0-based). + final int currentStep; + + /// The total number of steps. + final int totalSteps; + + /// Callback when the back button is tapped. + final VoidCallback? onBackTap; @override /// Builds the header UI. diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart index a9458571..c6f4c5e2 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart @@ -9,6 +9,15 @@ import 'package:staff_authentication/src/presentation/widgets/common/section_tit /// A widget for setting up preferred work locations and distance. class ProfileSetupLocation extends StatefulWidget { + + /// Creates a [ProfileSetupLocation] widget. + const ProfileSetupLocation({ + super.key, + required this.preferredLocations, + required this.maxDistanceMiles, + required this.onLocationsChanged, + required this.onDistanceChanged, + }); /// The list of preferred locations. final List preferredLocations; @@ -21,15 +30,6 @@ class ProfileSetupLocation extends StatefulWidget { /// Callback for when the max distance changes. final ValueChanged onDistanceChanged; - /// Creates a [ProfileSetupLocation] widget. - const ProfileSetupLocation({ - super.key, - required this.preferredLocations, - required this.maxDistanceMiles, - required this.onLocationsChanged, - required this.onDistanceChanged, - }); - @override State createState() => _ProfileSetupLocationState(); } @@ -97,9 +97,9 @@ class _ProfileSetupLocationState extends State { // Suggestions List BlocBuilder( - buildWhen: (previous, current) => + buildWhen: (ProfileSetupState previous, ProfileSetupState current) => previous.locationSuggestions != current.locationSuggestions, - builder: (context, state) { + builder: (BuildContext context, ProfileSetupState state) { if (state.locationSuggestions.isEmpty) { return const SizedBox.shrink(); } @@ -114,9 +114,9 @@ class _ProfileSetupLocationState extends State { shrinkWrap: true, padding: EdgeInsets.zero, itemCount: state.locationSuggestions.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final suggestion = state.locationSuggestions[index]; + separatorBuilder: (BuildContext context, int index) => const Divider(height: 1), + itemBuilder: (BuildContext context, int index) { + final String suggestion = state.locationSuggestions[index]; return ListTile( title: Text(suggestion, style: UiTypography.body2m), leading: const Icon(UiIcons.mapPin, size: 16), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index e0426496..b9721c85 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -28,9 +28,9 @@ class StaffAuthenticationModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton(AuthRepositoryImpl.new); i.addLazySingleton(ProfileSetupRepositoryImpl.new); i.addLazySingleton(PlaceRepositoryImpl.new); + i.addLazySingleton(AuthRepositoryImpl.new); // UseCases i.addLazySingleton(SignInWithPhoneUseCase.new); @@ -53,6 +53,7 @@ class StaffAuthenticationModule extends Module { ); } + @override void routes(RouteManager r) { r.child(StaffPaths.root, child: (_) => const IntroPage()); diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart index 62bd200a..6ccd905d 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart @@ -31,7 +31,7 @@ class AvailabilityBloc extends Bloc ) async { emit(AvailabilityLoading()); await handleError( - emit: emit, + emit: emit.call, action: () async { final days = await getWeeklyAvailability( GetWeeklyAvailabilityParams(event.weekStart), @@ -103,7 +103,7 @@ class AvailabilityBloc extends Bloc )); await handleError( - emit: emit, + emit: emit.call, action: () async { await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); // Success feedback @@ -155,7 +155,7 @@ class AvailabilityBloc extends Bloc )); await handleError( - emit: emit, + emit: emit.call, action: () async { await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); // Success feedback @@ -195,7 +195,7 @@ class AvailabilityBloc extends Bloc ); await handleError( - emit: emit, + emit: emit.call, action: () async { final newDays = await applyQuickSet( ApplyQuickSetParams(currentState.currentWeekStart, event.type), diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index 186511e7..e7cb7754 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -404,7 +404,7 @@ class _AvailabilityPageState extends State { value: isAvailable, onChanged: (val) => context.read().add(ToggleDayStatus(day)), - activeColor: UiColors.primary, + activeThumbColor: UiColors.primary, ), ], ), @@ -417,7 +417,7 @@ class _AvailabilityPageState extends State { final uiConfig = _getSlotUiConfig(slot.id); return _buildTimeSlotItem(context, day, slot, uiConfig); - }).toList(), + }), ], ), ); diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart index 98937517..7d596b28 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; diff --git a/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart b/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart index 07f01569..bd37f1ed 100644 --- a/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart +++ b/apps/mobile/packages/features/staff/availability/lib/staff_availability.dart @@ -1,3 +1,3 @@ -library staff_availability; +library; export 'src/staff_availability_module.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index acbb57ee..5f5c3650 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -49,7 +49,7 @@ class ClockInBloc extends Bloc ) async { emit(state.copyWith(status: ClockInStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final List shifts = await _getTodaysShift(); final AttendanceStatus status = await _getAttendanceStatus(); @@ -88,7 +88,7 @@ class ClockInBloc extends Bloc Emitter emit, ) async { await handleError( - emit: emit, + emit: emit.call, action: () async { LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { @@ -203,7 +203,7 @@ class ClockInBloc extends Bloc ) async { emit(state.copyWith(status: ClockInStatus.actionInProgress)); await handleError( - emit: emit, + emit: emit.call, action: () async { final AttendanceStatus newStatus = await _clockIn( ClockInArguments(shiftId: event.shiftId, notes: event.notes), @@ -226,7 +226,7 @@ class ClockInBloc extends Bloc ) async { emit(state.copyWith(status: ClockInStatus.actionInProgress)); await handleError( - emit: emit, + emit: emit.call, action: () async { final AttendanceStatus newStatus = await _clockOut( ClockOutArguments( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 87df8371..3e6ce143 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -32,7 +33,7 @@ class _ClockInPageState extends State { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.clock_in; + final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; return BlocProvider.value( value: _bloc, child: BlocConsumer( @@ -185,7 +186,7 @@ class _ClockInPageState extends State { style: UiTypography.body2b, ), Text( - "${shift.clientName} โ€ข ${shift.location}", + "${shift.clientName} รขโ‚ฌยข ${shift.location}", style: UiTypography.body3r .textSecondary, ), @@ -256,10 +257,83 @@ class _ClockInPageState extends State { ], ), ) - else + else ...[ + // Attire Photo Section + if (!isCheckedIn) ...[ + Container( + padding: const EdgeInsets.all(UiConstants.space4), + margin: const EdgeInsets.only(bottom: UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusMd, + ), + child: const Icon(UiIcons.camera, color: UiColors.primary), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(i18n.attire_photo_label, style: UiTypography.body2b), + Text(i18n.attire_photo_desc, style: UiTypography.body3r.textSecondary), + ], + ), + ), + UiButton.secondary( + text: i18n.take_attire_photo, + onPressed: () { + UiSnackbar.show( + context, + message: i18n.attire_captured, + type: UiSnackbarType.success, + ); + }, + ), + ], + ), + ), + ], + + if (!isCheckedIn && (!state.isLocationVerified || state.currentLocation == null)) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + margin: const EdgeInsets.only(bottom: UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.tagError, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + children: [ + const Icon(UiIcons.error, color: UiColors.textError, size: 20), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + state.currentLocation == null + ? i18n.location_verifying + : i18n.not_in_range(distance: '500'), + style: UiTypography.body3m.textError, + ), + ), + ], + ), + ), + ], + SwipeToCheckIn( isCheckedIn: isCheckedIn, mode: state.checkInMode, + isDisabled: !isCheckedIn && !state.isLocationVerified, isLoading: state.status == ClockInStatus.actionInProgress, @@ -292,6 +366,7 @@ class _ClockInPageState extends State { ); }, ), + ], ] else if (selectedShift != null && checkOutTime != null) ...[ // Shift Completed State @@ -479,7 +554,7 @@ class _ClockInPageState extends State { } Future _showNFCDialog(BuildContext context) async { - final i18n = Translations.of(context).staff.clock_in; + final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; bool scanned = false; // Using a local navigator context since we are in a dialog @@ -622,8 +697,10 @@ class _ClockInPageState extends State { final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); return DateFormat('h:mm a').format(windowStart); } catch (e) { - final i18n = Translations.of(context).staff.clock_in; + final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; return i18n.soon; } } } + + diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index bc1ddc3a..1c441d99 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -132,7 +133,7 @@ class _CommuteTrackerState extends State { @override Widget build(BuildContext context) { final CommuteMode mode = _getAppMode(); - final i18n = Translations.of(context).staff.clock_in.commute; + final TranslationsStaffClockInCommuteEn i18n = Translations.of(context).staff.clock_in.commute; // Notify parent of mode change WidgetsBinding.instance.addPostFrameCallback((_) { @@ -501,7 +502,7 @@ class _CommuteTrackerState extends State { margin: const EdgeInsets.only(bottom: UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5), decoration: BoxDecoration( - gradient: LinearGradient( + gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ @@ -549,3 +550,4 @@ class _CommuteTrackerState extends State { ); } } + diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 2f29a8f0..47ceb80d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -39,7 +40,7 @@ class _LunchBreakDialogState extends State { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.clock_in.lunch_break; + final TranslationsStaffClockInLunchBreakEn i18n = Translations.of(context).staff.clock_in.lunch_break; return Dialog( backgroundColor: UiColors.white, shape: RoundedRectangleBorder( @@ -171,7 +172,7 @@ class _LunchBreakDialogState extends State { Expanded( child: DropdownButtonFormField( isExpanded: true, - value: _breakStart, + initialValue: _breakStart, items: _timeOptions .map( (String t) => DropdownMenuItem( @@ -194,7 +195,7 @@ class _LunchBreakDialogState extends State { Expanded( child: DropdownButtonFormField( isExpanded: true, - value: _breakEnd, + initialValue: _breakEnd, items: _timeOptions .map( (String t) => DropdownMenuItem( @@ -336,3 +337,5 @@ class _LunchBreakDialogState extends State { ); } } + + diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index b62120bc..25113d73 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -10,12 +11,14 @@ class SwipeToCheckIn extends StatefulWidget { this.isLoading = false, this.mode = 'swipe', this.isCheckedIn = false, + this.isDisabled = false, }); final VoidCallback? onCheckIn; final VoidCallback? onCheckOut; final bool isLoading; final String mode; // 'swipe' or 'nfc' final bool isCheckedIn; + final bool isDisabled; @override State createState() => _SwipeToCheckInState(); @@ -39,7 +42,7 @@ class _SwipeToCheckInState extends State } void _onDragUpdate(DragUpdateDetails details, double maxWidth) { - if (_isComplete || widget.isLoading) return; + if (_isComplete || widget.isLoading || widget.isDisabled) return; setState(() { _dragValue = (_dragValue + details.delta.dx).clamp( 0.0, @@ -49,7 +52,7 @@ class _SwipeToCheckInState extends State } void _onDragEnd(DragEndDetails details, double maxWidth) { - if (_isComplete || widget.isLoading) return; + if (_isComplete || widget.isLoading || widget.isDisabled) return; final double threshold = (maxWidth - _handleSize - 8) * 0.8; if (_dragValue > threshold) { setState(() { @@ -72,7 +75,7 @@ class _SwipeToCheckInState extends State @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.clock_in.swipe; + final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe; final Color baseColor = widget.isCheckedIn ? UiColors.success : UiColors.primary; @@ -80,7 +83,7 @@ class _SwipeToCheckInState extends State if (widget.mode == 'nfc') { return GestureDetector( onTap: () { - if (widget.isLoading) return; + if (widget.isLoading || widget.isDisabled) return; // Simulate completion for NFC tap Future.delayed(const Duration(milliseconds: 300), () { if (widget.isCheckedIn) { @@ -93,9 +96,9 @@ class _SwipeToCheckInState extends State child: Container( height: 56, decoration: BoxDecoration( - color: baseColor, + color: widget.isDisabled ? UiColors.bgSecondary : baseColor, borderRadius: UiConstants.radiusLg, - boxShadow: [ + boxShadow: widget.isDisabled ? [] : [ BoxShadow( color: baseColor.withValues(alpha: 0.4), blurRadius: 25, @@ -115,7 +118,9 @@ class _SwipeToCheckInState extends State ? i18n.checking_out : i18n.checking_in) : (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin), - style: UiTypography.body1b.white, + style: UiTypography.body1b.copyWith( + color: widget.isDisabled ? UiColors.textDisabled : UiColors.white, + ), ), ], ), @@ -136,8 +141,10 @@ class _SwipeToCheckInState extends State final Color endColor = widget.isCheckedIn ? UiColors.primary : UiColors.success; - final Color currentColor = - Color.lerp(startColor, endColor, progress) ?? startColor; + + final Color currentColor = widget.isDisabled + ? UiColors.bgSecondary + : (Color.lerp(startColor, endColor, progress) ?? startColor); return Container( height: 56, @@ -161,7 +168,9 @@ class _SwipeToCheckInState extends State widget.isCheckedIn ? i18n.swipe_checkout : i18n.swipe_checkin, - style: UiTypography.body1b, + style: UiTypography.body1b.copyWith( + color: widget.isDisabled ? UiColors.textDisabled : UiColors.white, + ), ), ), ), @@ -169,7 +178,9 @@ class _SwipeToCheckInState extends State Center( child: Text( widget.isCheckedIn ? i18n.checkout_complete : i18n.checkin_complete, - style: UiTypography.body1b, + style: UiTypography.body1b.copyWith( + color: widget.isDisabled ? UiColors.textDisabled : UiColors.white, + ), ), ), Positioned( @@ -197,7 +208,7 @@ class _SwipeToCheckInState extends State child: Center( child: Icon( _isComplete ? UiIcons.check : UiIcons.arrowRight, - color: startColor, + color: widget.isDisabled ? UiColors.iconDisabled : startColor, ), ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 61de301e..a567b5e9 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -105,7 +105,7 @@ class HomeRepositoryImpl address: staff.addres, avatar: staff.photoUrl, ), - ownerId: session?.ownerId, + ownerId: staff.ownerId, ); StaffSessionStore.instance.setSession(updatedSession); @@ -113,6 +113,25 @@ class HomeRepositoryImpl }); } + @override + Future> getBenefits() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .listBenefitsDataByStaffId(staffId: staffId) + .execute(); + + return response.data.benefitsDatas.map((data) { + final plan = data.vendorBenefitPlan; + return Benefit( + title: plan.title, + entitlementHours: plan.total?.toDouble() ?? 0.0, + usedHours: data.current.toDouble(), + ); + }).toList(); + }); + } + // Mappers specific to Home's Domain Entity 'Shift' // Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift. diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart index df35f9d2..0b2b9f0d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart @@ -17,4 +17,7 @@ abstract class HomeRepository { /// Retrieves the current staff member's name. Future getStaffName(); + + /// Retrieves the list of benefits for the staff member. + Future> getBenefits(); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart index a0e158ee..f77e1614 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; @@ -13,10 +14,19 @@ class HomeCubit extends Cubit with BlocErrorHandler { final GetHomeShifts _getHomeShifts; final HomeRepository _repository; - HomeCubit(HomeRepository repository) - : _getHomeShifts = GetHomeShifts(repository), - _repository = repository, - super(const HomeState.initial()); + /// Use case that checks whether the staff member's personal info is complete. + /// + /// Used to determine whether profile-gated features (such as shift browsing) + /// should be enabled on the home screen. + final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletion; + + HomeCubit({ + required HomeRepository repository, + required GetPersonalInfoCompletionUseCase getPersonalInfoCompletion, + }) : _getHomeShifts = GetHomeShifts(repository), + _repository = repository, + _getPersonalInfoCompletion = getPersonalInfoCompletion, + super(const HomeState.initial()); Future loadShifts() async { if (isClosed) return; @@ -24,29 +34,39 @@ class HomeCubit extends Cubit with BlocErrorHandler { await handleError( emit: emit, action: () async { - final result = await _getHomeShifts.call(); - final name = await _repository.getStaffName(); + // Fetch shifts, name, benefits and profile completion status concurrently + final results = await Future.wait([ + _getHomeShifts.call(), + _getPersonalInfoCompletion.call(), + _repository.getBenefits(), + _repository.getStaffName(), + ]); + + final homeResult = results[0] as HomeShifts; + final isProfileComplete = results[1] as bool; + final benefits = results[2] as List; + final name = results[3] as String?; + if (isClosed) return; emit( state.copyWith( status: HomeStatus.loaded, - todayShifts: result.today, - tomorrowShifts: result.tomorrow, - recommendedShifts: result.recommended, + todayShifts: homeResult.today, + tomorrowShifts: homeResult.tomorrow, + recommendedShifts: homeResult.recommended, staffName: name, - // Mock profile status for now, ideally fetched from a user repository - isProfileComplete: false, + isProfileComplete: isProfileComplete, + benefits: benefits, ), ); }, onError: (String errorKey) { - if (isClosed) return state; // Avoid state emission if closed, though emit handles it gracefully usually + if (isClosed) return state; return state.copyWith(status: HomeStatus.error, errorMessage: errorKey); }, ); } - void toggleAutoMatch(bool enabled) { emit(state.copyWith(autoMatchEnabled: enabled)); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart index 0713d7a1..48a87e92 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart @@ -11,6 +11,7 @@ class HomeState extends Equatable { final bool isProfileComplete; final String? staffName; final String? errorMessage; + final List benefits; const HomeState({ required this.status, @@ -21,6 +22,7 @@ class HomeState extends Equatable { this.isProfileComplete = false, this.staffName, this.errorMessage, + this.benefits = const [], }); const HomeState.initial() : this(status: HomeStatus.initial); @@ -34,6 +36,7 @@ class HomeState extends Equatable { bool? isProfileComplete, String? staffName, String? errorMessage, + List? benefits, }) { return HomeState( status: status ?? this.status, @@ -44,6 +47,7 @@ class HomeState extends Equatable { isProfileComplete: isProfileComplete ?? this.isProfileComplete, staffName: staffName ?? this.staffName, errorMessage: errorMessage ?? this.errorMessage, + benefits: benefits ?? this.benefits, ); } @@ -57,5 +61,6 @@ class HomeState extends Equatable { isProfileComplete, staffName, errorMessage, + benefits, ]; } \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart new file mode 100644 index 00000000..271bc46c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -0,0 +1,407 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'dart:math' as math; + +/// Page displaying a detailed overview of the worker's benefits. +class BenefitsOverviewPage extends StatelessWidget { + /// Creates a [BenefitsOverviewPage]. + const BenefitsOverviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: Modular.get(), + child: Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: _buildAppBar(context), + body: BlocBuilder( + builder: (context, state) { + if (state.status == HomeStatus.loading || + state.status == HomeStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == HomeStatus.error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Text( + state.errorMessage ?? t.staff.home.benefits.overview.subtitle, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + ), + ); + } + + final benefits = state.benefits; + if (benefits.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Text( + t.staff.home.benefits.overview.subtitle, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space6, + bottom: 120, + ), + itemCount: benefits.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: _BenefitCard(benefit: benefits[index]), + ); + }, + ); + }, + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconPrimary), + onPressed: () => Navigator.of(context).pop(), + ), + centerTitle: true, + title: Column( + children: [ + Text( + t.staff.home.benefits.overview.title, + style: UiTypography.title2b.textPrimary, + ), + const SizedBox(height: 2), + Text( + t.staff.home.benefits.overview.subtitle, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border.withOpacity(0.5), height: 1), + ), + ); + } + } + + class _BenefitCard extends StatelessWidget { + final Benefit benefit; + + const _BenefitCard({required this.benefit}); + + @override + Widget build(BuildContext context) { + final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); + final bool isVacation = benefit.title.toLowerCase().contains('vacation'); + final bool isHolidays = benefit.title.toLowerCase().contains('holiday'); + + final i18n = t.staff.home.benefits.overview; + + return Container( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildProgressCircle(), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + benefit.title, + style: UiTypography.body1b.textPrimary, + ), + const Icon(UiIcons.info, size: 18, color: Color(0xFFE2E8F0)), + ], + ), + const SizedBox(height: 4), + Text( + _getSubtitle(benefit.title), + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + if (isSickLeave) ...[ + _AccordionHistory(label: i18n.sick_leave_history), + const SizedBox(height: UiConstants.space6), + ], + if (isVacation || isHolidays) ...[ + _buildComplianceBanner(i18n.compliance_banner), + const SizedBox(height: UiConstants.space6), + ], + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: i18n.request_payment(benefit: benefit.title), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0038A8), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + // TODO: Implement payment request + UiSnackbar.show(context, message: i18n.request_submitted(benefit: benefit.title), type: UiSnackbarType.success); + }, + ), + ), + ], + ), + ); + } + + Widget _buildProgressCircle() { + final double progress = benefit.entitlementHours > 0 + ? (benefit.remainingHours / benefit.entitlementHours) + : 0.0; + + final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); + final Color circleColor = isSickLeave ? const Color(0xFF2563EB) : const Color(0xFF10B981); + + return SizedBox( + width: 72, + height: 72, + child: CustomPaint( + painter: _CircularProgressPainter( + progress: progress, + color: circleColor, + backgroundColor: const Color(0xFFE2E8F0), + strokeWidth: 6, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', + style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), + ), + Text( + t.client_billing.hours_suffix, + style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9), + ), + ], + ), + ), + ), + ); + } + + String _getSubtitle(String title) { + final i18n = t.staff.home.benefits.overview; + if (title.toLowerCase().contains('sick')) { + return i18n.sick_leave_subtitle; + } else if (title.toLowerCase().contains('vacation')) { + return i18n.vacation_subtitle; + } else if (title.toLowerCase().contains('holiday')) { + return i18n.holidays_subtitle; + } + return ''; + } + + Widget _buildComplianceBanner(String text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(UiIcons.checkCircle, size: 16, color: Color(0xFF10B981)), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: UiTypography.footnote1r.copyWith( + color: const Color(0xFF065F46), + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + } + +class _CircularProgressPainter extends CustomPainter { + final double progress; + final Color color; + final Color backgroundColor; + final double strokeWidth; + + _CircularProgressPainter({ + required this.progress, + required this.color, + required this.backgroundColor, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + canvas.drawCircle(center, radius, backgroundPaint); + + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + final sweepAngle = 2 * math.pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class _AccordionHistory extends StatefulWidget { + final String label; + + const _AccordionHistory({required this.label}); + + @override + State<_AccordionHistory> createState() => _AccordionHistoryState(); +} + +class _AccordionHistoryState extends State<_AccordionHistory> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, color: Color(0xFFE2E8F0)), + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.label, + style: UiTypography.footnote2b.textSecondary.copyWith( + letterSpacing: 0.5, + fontSize: 11, + ), + ), + Icon( + _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + if (_isExpanded) ...[ + _buildHistoryItem('1 Jan, 2024', 'Pending', const Color(0xFFF1F5F9), const Color(0xFF64748B)), + const SizedBox(height: 14), + _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), + const SizedBox(height: 14), + _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), + const SizedBox(height: 14), + _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), + const SizedBox(height: 14), + _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), + const SizedBox(height: 4), + ] + ], + ); + } + + Widget _buildHistoryItem(String date, String status, Color bgColor, Color textColor) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + date, + style: UiTypography.footnote1r.textSecondary.copyWith( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: status == 'Pending' ? Border.all(color: const Color(0xFFE2E8F0)) : null, + ), + child: Text( + status, + style: UiTypography.footnote2m.copyWith( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 7de014d6..409dae51 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -13,6 +13,7 @@ import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item. import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart'; import 'package:staff_home/src/presentation/widgets/shift_card.dart'; +import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart'; /// The home page for the staff worker application. /// @@ -49,25 +50,26 @@ class WorkerHomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ BlocBuilder( - buildWhen: (previous, current) => previous.staffName != current.staffName, + buildWhen: (previous, current) => + previous.staffName != current.staffName, builder: (context, state) { return HomeHeader(userName: state.staffName); }, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), child: Column( children: [ BlocBuilder( - buildWhen: (previous, current) => - previous.isProfileComplete != - current.isProfileComplete, builder: (context, state) { if (state.isProfileComplete) return const SizedBox(); return PlaceholderBanner( title: bannersI18n.complete_profile_title, subtitle: bannersI18n.complete_profile_subtitle, - bg: UiColors.bgHighlight, + bg: UiColors.primaryInverse, accent: UiColors.primary, onTap: () { Modular.to.toProfile(); @@ -135,7 +137,8 @@ class WorkerHomePage extends StatelessWidget { EmptyStateWidget( message: emptyI18n.no_shifts_today, actionLink: emptyI18n.find_shifts_cta, - onAction: () => Modular.to.toShifts(initialTab: 'find'), + onAction: () => + Modular.to.toShifts(initialTab: 'find'), ) else Column( @@ -152,7 +155,7 @@ class WorkerHomePage extends StatelessWidget { ); }, ), - const SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space3), // Tomorrow's Shifts BlocBuilder( @@ -180,12 +183,10 @@ class WorkerHomePage extends StatelessWidget { ); }, ), - const SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space3), // Recommended Shifts - SectionHeader( - title: sectionsI18n.recommended_for_you, - ), + SectionHeader(title: sectionsI18n.recommended_for_you), BlocBuilder( builder: (context, state) { if (state.recommendedShifts.isEmpty) { @@ -201,7 +202,8 @@ class WorkerHomePage extends StatelessWidget { clipBehavior: Clip.none, itemBuilder: (context, index) => Padding( padding: const EdgeInsets.only( - right: UiConstants.space3), + right: UiConstants.space3, + ), child: RecommendedShiftCard( shift: state.recommendedShifts[index], ), @@ -211,6 +213,16 @@ class WorkerHomePage extends StatelessWidget { }, ), const SizedBox(height: UiConstants.space6), + + // Benefits + BlocBuilder( + buildWhen: (previous, current) => + previous.benefits != current.benefits, + builder: (context, state) { + return BenefitsWidget(benefits: state.benefits); + }, + ), + const SizedBox(height: UiConstants.space6), ], ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/empty_state_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/empty_state_widget.dart index bd52d67d..e61ac1d4 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/empty_state_widget.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/empty_state_widget.dart @@ -19,26 +19,61 @@ class EmptyStateWidget extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space6), decoration: BoxDecoration( - color: UiColors.bgSecondary, + color: UiColors.bgSecondary.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.border.withValues(alpha: 0.5), + style: BorderStyle.solid, + ), ), alignment: Alignment.center, child: Column( children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + UiIcons.info, + size: 20, + color: UiColors.mutedForeground.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: UiConstants.space3), Text( message, - style: UiTypography.body2r.copyWith(color: UiColors.mutedForeground), + style: UiTypography.body2m.copyWith(color: UiColors.mutedForeground), + textAlign: TextAlign.center, ), if (actionLink != null) GestureDetector( onTap: onAction, child: Padding( - padding: const EdgeInsets.only(top: UiConstants.space2), - child: Text( - actionLink!, - style: UiTypography.body2m.copyWith(color: UiColors.primary), + padding: const EdgeInsets.only(top: UiConstants.space3), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusFull, + ), + child: Text( + actionLink!, + style: UiTypography.body3m.copyWith(color: UiColors.primary), + ), ), ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/pending_payment_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/pending_payment_card.dart index 4476aecc..77fe1ff1 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/pending_payment_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/pending_payment_card.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; - import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_core/core.dart'; - /// Card widget for displaying pending payment information, using design system tokens. class PendingPaymentCard extends StatelessWidget { /// Creates a [PendingPaymentCard]. @@ -21,7 +19,10 @@ class PendingPaymentCard extends StatelessWidget { padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( gradient: LinearGradient( - colors: [UiColors.primary.withOpacity(0.08), UiColors.primary.withOpacity(0.04)], + colors: [ + UiColors.primary.withOpacity(0.08), + UiColors.primary.withOpacity(0.04), + ], begin: Alignment.centerLeft, end: Alignment.centerRight, ), @@ -59,7 +60,9 @@ class PendingPaymentCard extends StatelessWidget { ), Text( pendingI18n.subtitle, - style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground), + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), overflow: TextOverflow.ellipsis, ), ], @@ -70,10 +73,7 @@ class PendingPaymentCard extends StatelessWidget { ), Row( children: [ - Text( - '\$285.00', - style: UiTypography.headline4m, - ), + Text('\$285.00', style: UiTypography.headline4m), SizedBox(width: UiConstants.space2), Icon( UiIcons.chevronRight, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart index 1d648bc4..af821f42 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/placeholder_banner.dart @@ -1,24 +1,33 @@ import 'package:flutter/material.dart'; - import 'package:design_system/design_system.dart'; - /// Banner widget for placeholder actions, using design system tokens. class PlaceholderBanner extends StatelessWidget { /// Banner title final String title; + /// Banner subtitle final String subtitle; + /// Banner background color final Color bg; + /// Banner accent color final Color accent; + /// Optional tap callback final VoidCallback? onTap; /// Creates a [PlaceholderBanner]. - const PlaceholderBanner({super.key, required this.title, required this.subtitle, required this.bg, required this.accent, this.onTap}); + const PlaceholderBanner({ + super.key, + required this.title, + required this.subtitle, + required this.bg, + required this.accent, + this.onTap, + }); @override Widget build(BuildContext context) { @@ -29,7 +38,7 @@ class PlaceholderBanner extends StatelessWidget { decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: accent.withValues(alpha: 0.3)), + border: Border.all(color: accent, width: 1), ), child: Row( children: [ @@ -41,7 +50,11 @@ class PlaceholderBanner extends StatelessWidget { color: UiColors.bgBanner, shape: BoxShape.circle, ), - child: Icon(UiIcons.sparkles, color: accent, size: UiConstants.space5), + child: Icon( + UiIcons.sparkles, + color: accent, + size: UiConstants.space5, + ), ), const SizedBox(width: UiConstants.space3), Expanded( @@ -50,12 +63,9 @@ class PlaceholderBanner extends StatelessWidget { children: [ Text( title, - style: UiTypography.body1b, - ), - Text( - subtitle, - style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground), + style: UiTypography.body1b.copyWith(color: accent), ), + Text(subtitle, style: UiTypography.body3r.textSecondary), ], ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index e5ead2d2..1dd260f2 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -2,9 +2,8 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_domain/krow_domain.dart'; - import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; class RecommendedShiftCard extends StatelessWidget { final Shift shift; @@ -17,7 +16,7 @@ class RecommendedShiftCard extends StatelessWidget { return GestureDetector( onTap: () { - Modular.to.pushShiftDetails(shift); + Modular.to.toShiftDetails(shift); }, child: Container( width: 300, @@ -43,7 +42,9 @@ class RecommendedShiftCard extends StatelessWidget { children: [ Text( recI18n.act_now, - style: UiTypography.body3m.copyWith(color: UiColors.textError), + style: UiTypography.body3m.copyWith( + color: UiColors.textError, + ), ), const SizedBox(width: UiConstants.space2), Container( @@ -71,7 +72,9 @@ class RecommendedShiftCard extends StatelessWidget { height: UiConstants.space10, decoration: BoxDecoration( color: UiColors.tagInProgress, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), child: const Icon( UiIcons.calendar, @@ -128,10 +131,7 @@ class RecommendedShiftCard extends StatelessWidget { color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), - Text( - recI18n.today, - style: UiTypography.body3r.textSecondary, - ), + Text(recI18n.today, style: UiTypography.body3r.textSecondary), const SizedBox(width: UiConstants.space3), const Icon( UiIcons.clock, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart index e38da6e4..c5e7f4fa 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart @@ -1,19 +1,25 @@ import 'package:flutter/material.dart'; - import 'package:design_system/design_system.dart'; /// Section header widget for home page sections, using design system tokens. class SectionHeader extends StatelessWidget { /// Section title final String title; + /// Optional action label final String? action; + /// Optional action callback final VoidCallback? onAction; /// Creates a [SectionHeader]. - const SectionHeader({super.key, required this.title, this.action, this.onAction}); + const SectionHeader({ + super.key, + required this.title, + this.action, + this.onAction, + }); @override Widget build(BuildContext context) { @@ -27,19 +33,13 @@ class SectionHeader extends StatelessWidget { ? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: UiTypography.body2m.textPrimary, - ), + Text(title, style: UiTypography.body1b), if (onAction != null) GestureDetector( onTap: onAction, child: Row( children: [ - Text( - action ?? '', - style: UiTypography.body3r.textPrimary, - ), + Text(action ?? '', style: UiTypography.body3r), const Icon( UiIcons.chevronRight, size: UiConstants.space4, @@ -56,23 +56,20 @@ class SectionHeader extends StatelessWidget { ), decoration: BoxDecoration( color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + borderRadius: UiConstants.radiusMd, border: Border.all( - color: UiColors.primary.withValues(alpha: 0.2), + color: UiColors.primary, + width: 0.5, ), ), child: Text( action!, - style: UiTypography.body3r.textPrimary, + style: UiTypography.body3r.primary, ), ), ], ) - : Text( - title, - style: UiTypography.body2m.textPrimary, - ), + : Text(title, style: UiTypography.body1b), ), ], ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart index f35d97ae..fd484758 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart @@ -60,7 +60,8 @@ class _ShiftCardState extends State { final date = DateTime.parse(dateStr); final diff = DateTime.now().difference(date); if (diff.inHours < 1) return t.staff_shifts.card.just_now; - if (diff.inHours < 24) return t.staff_shifts.details.pending_time(time: '${diff.inHours}h'); + if (diff.inHours < 24) + return t.staff_shifts.details.pending_time(time: '${diff.inHours}h'); return t.staff_shifts.details.pending_time(time: '${diff.inDays}d'); } catch (e) { return ''; @@ -75,7 +76,7 @@ class _ShiftCardState extends State { ? null : () { setState(() => isExpanded = !isExpanded); - Modular.to.pushShiftDetails(widget.shift); + Modular.to.toShiftDetails(widget.shift); }, child: Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -97,17 +98,15 @@ class _ShiftCardState extends State { ), child: widget.shift.logoUrl != null ? ClipRRect( - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), child: Image.network( widget.shift.logoUrl!, fit: BoxFit.contain, ), ) - : Icon( - UiIcons.building, - color: UiColors.mutedForeground, - ), + : Icon(UiIcons.building, color: UiColors.mutedForeground), ), const SizedBox(width: UiConstants.space3), Expanded( @@ -126,13 +125,10 @@ class _ShiftCardState extends State { ), Text.rich( TextSpan( - text: '\$${widget.shift.hourlyRate}', + text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary, children: [ - TextSpan( - text: '/h', - style: UiTypography.body3r, - ), + TextSpan(text: '/h', style: UiTypography.body3r), ], ), ), @@ -186,13 +182,16 @@ class _ShiftCardState extends State { height: UiConstants.space14, decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), border: Border.all(color: UiColors.border), ), child: widget.shift.logoUrl != null ? ClipRRect( - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), child: Image.network( widget.shift.logoUrl!, fit: BoxFit.contain, @@ -248,13 +247,10 @@ class _ShiftCardState extends State { ), Text.rich( TextSpan( - text: '\$${widget.shift.hourlyRate}', + text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}', style: UiTypography.headline3m.textPrimary, children: [ - TextSpan( - text: '/h', - style: UiTypography.body1r, - ), + TextSpan(text: '/h', style: UiTypography.body1r), ], ), ), @@ -336,8 +332,9 @@ class _ShiftCardState extends State { foregroundColor: UiColors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), ), child: Text(t.staff_shifts.card.accept_shift), @@ -355,8 +352,9 @@ class _ShiftCardState extends State { color: UiColors.destructive.withValues(alpha: 0.3), ), shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), ), child: Text(t.staff_shifts.card.decline_shift), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart index 886a44e4..84031223 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart @@ -1,84 +1,90 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; - import 'dart:math' as math; import 'package:core_localization/core_localization.dart'; - +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Widget for displaying staff benefits, using design system tokens. class BenefitsWidget extends StatelessWidget { + /// The list of benefits to display. + final List benefits; + /// Creates a [BenefitsWidget]. - const BenefitsWidget({super.key}); + const BenefitsWidget({ + required this.benefits, + super.key, + }); @override Widget build(BuildContext context) { final i18n = t.staff.home.benefits; + + if (benefits.isEmpty) { + return const SizedBox.shrink(); + } + return Container( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - boxShadow: [ + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withOpacity(0.5)), + boxShadow: [ BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), + color: UiColors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 4), ), ], ), child: Column( - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Text( i18n.title, - style: UiTypography.title1m.textPrimary, + style: UiTypography.body1b.textPrimary, ), GestureDetector( - onTap: () => Modular.to.pushNamed('/benefits'), + onTap: () => Modular.to.toBenefits(), child: Row( - children: [ + children: [ Text( i18n.view_all, - style: UiTypography.buttonL.textPrimary, + style: UiTypography.footnote2r.copyWith( + color: const Color(0xFF2563EB), + fontWeight: FontWeight.w500, + ), ), - Icon( + const SizedBox(width: 4), + const Icon( UiIcons.chevronRight, - size: UiConstants.space4, - color: UiColors.primary, + size: 14, + color: Color(0xFF2563EB), ), ], ), ), ], ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _BenefitItem( - label: i18n.items.sick_days, - current: 10, - total: 40, - color: UiColors.primary, - ), - _BenefitItem( - label: i18n.items.vacation, - current: 40, - total: 40, - color: UiColors.primary, - ), - _BenefitItem( - label: i18n.items.holidays, - current: 24, - total: 24, - color: UiColors.primary, - ), - ], + children: benefits.map((Benefit benefit) { + return Expanded( + child: _BenefitItem( + label: benefit.title, + remaining: benefit.remainingHours, + total: benefit.entitlementHours, + used: benefit.usedHours, + color: const Color(0xFF2563EB), + ), + ); + }).toList(), ), ], ), @@ -88,53 +94,64 @@ class BenefitsWidget extends StatelessWidget { class _BenefitItem extends StatelessWidget { final String label; - final double current; + final double remaining; final double total; + final double used; final Color color; const _BenefitItem({ required this.label, - required this.current, + required this.remaining, required this.total, + required this.used, required this.color, }); @override Widget build(BuildContext context) { - final i18n = t.staff.home.benefits; + final double progress = total > 0 ? (remaining / total) : 0.0; + return Column( - children: [ + children: [ SizedBox( - width: UiConstants.space14, - height: UiConstants.space14, + width: 64, + height: 64, child: CustomPaint( painter: _CircularProgressPainter( - progress: current / total, + progress: progress, color: color, - backgroundColor: UiColors.border, - strokeWidth: 4, + backgroundColor: const Color(0xFFE2E8F0), + strokeWidth: 5, ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Text( - '${current.toInt()}/${total.toInt()}', - style: UiTypography.body3m.textPrimary, + '${remaining.toInt()}/${total.toInt()}', + style: UiTypography.body2b.textPrimary.copyWith( + fontSize: 12, + letterSpacing: -0.5, + ), ), Text( - i18n.hours_label, - style: UiTypography.footnote1r.textTertiary, + 'hours', + style: UiTypography.footnote2r.textTertiary.copyWith( + fontSize: 8, + ), ), ], ), ), ), ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space3), Text( label, - style: UiTypography.body3m.textSecondary, + style: UiTypography.footnote2r.textSecondary.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, ), ], ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 80710549..7945045f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_home/src/data/repositories/home_repository_impl.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart'; import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; /// The module for the staff home feature. @@ -14,11 +16,28 @@ import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; class StaffHomeModule extends Module { @override void binds(Injector i) { - // Repository + // Repository - provides home data (shifts, staff name) i.addLazySingleton(() => HomeRepositoryImpl()); + // StaffConnectorRepository for profile completion queries + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), + ); + + // Use case for checking personal info profile completion + i.addLazySingleton( + () => GetPersonalInfoCompletionUseCase( + repository: i.get(), + ), + ); + // Presentation layer - Cubit - i.addSingleton(HomeCubit.new); + i.addSingleton( + () => HomeCubit( + repository: i.get(), + getPersonalInfoCompletion: i.get(), + ), + ); } @override @@ -27,5 +46,9 @@ class StaffHomeModule extends Module { StaffPaths.childRoute(StaffPaths.home, StaffPaths.home), child: (BuildContext context) => const WorkerHomePage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits), + child: (BuildContext context) => const BenefitsOverviewPage(), + ); } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index 42cdb1af..726a84b1 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -1,3 +1,4 @@ +import 'package:firebase_data_connect/src/core/ref.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -16,7 +17,7 @@ class PaymentsRepositoryImpl final String currentStaffId = await _service.getStaffId(); // Fetch recent payments with a limit - final response = await _service.connector.listRecentPaymentsByStaffId( + final QueryResult response = await _service.connector.listRecentPaymentsByStaffId( staffId: currentStaffId, ).limit(100).execute(); @@ -61,7 +62,7 @@ class PaymentsRepositoryImpl return _service.run(() async { final String currentStaffId = await _service.getStaffId(); - final response = await _service.connector + final QueryResult response = await _service.connector .listRecentPaymentsByStaffId(staffId: currentStaffId) .execute(); diff --git a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart index 0225601a..6f30e5d5 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart @@ -7,6 +7,7 @@ import 'domain/usecases/get_payment_history_usecase.dart'; import 'data/repositories/payments_repository_impl.dart'; import 'presentation/blocs/payments/payments_bloc.dart'; import 'presentation/pages/payments_page.dart'; +import 'presentation/pages/early_pay_page.dart'; class StaffPaymentsModule extends Module { @override @@ -28,5 +29,9 @@ class StaffPaymentsModule extends Module { StaffPaths.childRoute(StaffPaths.payments, StaffPaths.payments), child: (BuildContext context) => const PaymentsPage(), ); + r.child( + '/early-pay', + child: (BuildContext context) => const EarlyPayPage(), + ); } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart index 0eba1ed5..f0e096db 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart @@ -25,7 +25,7 @@ class PaymentsBloc extends Bloc ) async { emit(PaymentsLoading()); await handleError( - emit: emit, + emit: emit.call, action: () async { final PaymentSummary currentSummary = await getPaymentSummary(); @@ -51,7 +51,7 @@ class PaymentsBloc extends Bloc final PaymentsState currentState = state; if (currentState is PaymentsLoaded) { await handleError( - emit: emit, + emit: emit.call, action: () async { final List newHistory = await getPaymentHistory( GetPaymentHistoryArguments(event.period), diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart new file mode 100644 index 00000000..d9c6716c --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/early_pay_page.dart @@ -0,0 +1,110 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; + +class EarlyPayPage extends StatelessWidget { + const EarlyPayPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.t.staff_payments.early_pay.title), + elevation: 0, + backgroundColor: UiColors.white, + foregroundColor: UiColors.primary, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.05), + borderRadius: UiConstants.radius2xl, + border: Border.all(color: UiColors.primary.withValues(alpha: 0.1)), + ), + child: Column( + children: [ + Text( + context.t.staff_payments.early_pay.available_label, + style: UiTypography.body2m.textSecondary, + ), + const SizedBox(height: 8), + Text( + '\$340.00', + style: UiTypography.secondaryDisplay1b.primary, + ), + ], + ), + ), + const SizedBox(height: 32), + Text( + context.t.staff_payments.early_pay.select_amount, + style: UiTypography.headline4m.textPrimary, + ), + const SizedBox(height: 16), + UiTextField( + hintText: context.t.staff_payments.early_pay.hint_amount, + keyboardType: TextInputType.number, + prefixIcon: UiIcons.chart, // Currency icon if available + ), + const SizedBox(height: 32), + Text( + context.t.staff_payments.early_pay.deposit_to, + style: UiTypography.body2b.textPrimary, + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.separatorPrimary), + ), + child: Row( + children: [ + const Icon(UiIcons.bank, size: 24, color: UiColors.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Chase Bank', style: UiTypography.body2b.textPrimary), + Text('Ending in 4321', style: UiTypography.footnote2r.textSecondary), + ], + ), + ), + const Icon(UiIcons.chevronRight, size: 18, color: UiColors.iconSecondary), + ], + ), + ), + const SizedBox(height: 40), + UiButton.primary( + text: context.t.staff_payments.early_pay.confirm_button, + fullWidth: true, + onPressed: () { + UiSnackbar.show( + context, + message: context.t.staff_payments.early_pay.success_message, + type: UiSnackbarType.success, + ); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + Center( + child: Text( + context.t.staff_payments.early_pay.fee_notice, + style: UiTypography.footnote2r.textSecondary, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index 8ad49155..b1ce9e4e 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -1,4 +1,5 @@ import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -37,7 +38,7 @@ class _PaymentsPageState extends State { child: Scaffold( backgroundColor: UiColors.background, body: BlocConsumer( - listener: (context, state) { + listener: (BuildContext context, PaymentsState state) { // Error is already shown on the page itself (lines 53-63), no need for snackbar }, builder: (BuildContext context, PaymentsState state) { @@ -178,7 +179,7 @@ class _PaymentsPageState extends State { PendingPayCard( amount: state.summary.pendingEarnings, onCashOut: () { - Modular.to.pushNamed('/early-pay'); + Modular.to.pushNamed('${StaffPaths.payments}early-pay'); }, ), const SizedBox(height: UiConstants.space6), diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart index 18a8ac89..4a7cc547 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart @@ -120,8 +120,17 @@ class EarningsGraph extends StatelessWidget { } List _generateSpots(List data) { + if (data.isEmpty) return []; + + // If only one data point, add a dummy point at the start to create a horizontal line + if (data.length == 1) { + return [ + FlSpot(0, data[0].amount), + FlSpot(1, data[0].amount), + ]; + } + // Generate spots based on index in the list for simplicity in this demo - // Real implementation would map to actual dates on X-axis return List.generate(data.length, (int index) { return FlSpot(index.toDouble(), data[index].amount); }); diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/pending_pay_card.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/pending_pay_card.dart index fe49fbf8..e0864f2e 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/pending_pay_card.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/pending_pay_card.dart @@ -60,6 +60,15 @@ class PendingPayCard extends StatelessWidget { ), ], ), + UiButton.secondary( + text: 'Early Pay', + onPressed: onCashOut, + size: UiButtonSize.small, + style: OutlinedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.primary, + ), + ), ], ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart deleted file mode 100644 index 42aa3a17..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../../domain/repositories/profile_repository.dart'; - -/// Implementation of [ProfileRepositoryInterface] that delegates to data_connect. -/// -/// This implementation follows Clean Architecture by: -/// - Implementing the domain layer's repository interface -/// - Delegating all data access to the data_connect package -/// - Not containing any business logic -/// - Only performing data transformation/mapping if needed -/// -/// Currently uses [ProfileRepositoryMock] from data_connect. -/// When Firebase Data Connect is ready, this will be swapped with a real implementation. -class ProfileRepositoryImpl - implements ProfileRepositoryInterface { - /// Creates a [ProfileRepositoryImpl]. - ProfileRepositoryImpl() : _service = DataConnectService.instance; - - final DataConnectService _service; - - @override - Future getStaffProfile() async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - final response = await _service.connector.getStaffById(id: staffId).execute(); - - if (response.data.staff == null) { - throw const ServerException(technicalMessage: 'Staff not found'); - } - - final GetStaffByIdStaff rawStaff = response.data.staff!; - - // Map the raw data connect object to the Domain Entity - return Staff( - id: rawStaff.id, - authProviderId: rawStaff.userId, - name: rawStaff.fullName, - email: rawStaff.email ?? '', - phone: rawStaff.phone, - avatar: rawStaff.photoUrl, - status: StaffStatus.active, - address: rawStaff.addres, - totalShifts: rawStaff.totalShifts, - averageRating: rawStaff.averageRating, - onTimeRate: rawStaff.onTimeRate, - noShowCount: rawStaff.noShowCount, - cancellationCount: rawStaff.cancellationCount, - reliabilityScore: rawStaff.reliabilityScore, - ); - }); - } - - @override - Future signOut() async { - try { - await _service.auth.signOut(); - _service.clearCache(); - } catch (e) { - throw Exception('Error signing out: ${e.toString()}'); - } - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart deleted file mode 100644 index 05868bbb..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:krow_domain/krow_domain.dart'; - -/// Repository interface for staff profile operations. -/// -/// Defines the contract for accessing and managing staff profile data. -/// This interface lives in the domain layer and is implemented by the data layer. -/// -/// Following Clean Architecture principles, this interface: -/// - Returns domain entities (Staff from shared domain package) -/// - Defines business requirements without implementation details -/// - Allows the domain layer to be independent of data sources -abstract interface class ProfileRepositoryInterface { - /// Fetches the staff profile for the current authenticated user. - /// - /// Returns a [Staff] entity from the shared domain package containing - /// all profile information. - /// - /// Throws an exception if the profile cannot be retrieved. - Future getStaffProfile(); - - /// Signs out the current user. - /// - /// Clears the user's session and authentication state. - /// Should be followed by navigation to the authentication flow. - Future signOut(); -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart deleted file mode 100644 index bb1a96d8..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../repositories/profile_repository.dart'; - -/// Use case for fetching a staff member's extended profile information. -/// -/// This use case: -/// 1. Fetches the [Staff] object from the repository -/// 2. Returns it directly to the presentation layer -/// -class GetProfileUseCase implements UseCase { - final ProfileRepositoryInterface _repository; - - /// Creates a [GetProfileUseCase]. - /// - /// Requires a [ProfileRepositoryInterface] to interact with the profile data source. - const GetProfileUseCase(this._repository); - - @override - Future call([void params]) async { - // Fetch staff object from repository and return directly - return await _repository.getStaffProfile(); - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart deleted file mode 100644 index 621d85a8..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:krow_core/core.dart'; - -import '../repositories/profile_repository.dart'; - -/// Use case for signing out the current user. -/// -/// This use case delegates the sign-out logic to the [ProfileRepositoryInterface]. -/// -/// Following Clean Architecture principles, this use case: -/// - Encapsulates the sign-out business rule -/// - Depends only on the repository interface -/// - Keeps the domain layer independent of external frameworks -class SignOutUseCase implements NoInputUseCase { - final ProfileRepositoryInterface _repository; - - /// Creates a [SignOutUseCase]. - /// - /// Requires a [ProfileRepositoryInterface] to perform the sign-out operation. - const SignOutUseCase(this._repository); - - @override - Future call() { - return _repository.signOut(); - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index f4cba322..141a9c2b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -1,7 +1,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import '../../domain/usecases/get_profile_usecase.dart'; -import '../../domain/usecases/sign_out_usecase.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; + import 'profile_state.dart'; /// Cubit for managing the Profile feature state. @@ -9,12 +10,22 @@ import 'profile_state.dart'; /// Handles loading profile data and user sign-out actions. class ProfileCubit extends Cubit with BlocErrorHandler { - final GetProfileUseCase _getProfileUseCase; - final SignOutUseCase _signOutUseCase; /// Creates a [ProfileCubit] with the required use cases. - ProfileCubit(this._getProfileUseCase, this._signOutUseCase) - : super(const ProfileState()); + ProfileCubit( + this._getProfileUseCase, + this._signOutUseCase, + this._getPersonalInfoCompletionUseCase, + this._getEmergencyContactsCompletionUseCase, + this._getExperienceCompletionUseCase, + this._getTaxFormsCompletionUseCase, + ) : super(const ProfileState()); + final GetStaffProfileUseCase _getProfileUseCase; + final SignOutStaffUseCase _signOutUseCase; + final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase; + final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase; + final GetExperienceCompletionUseCase _getExperienceCompletionUseCase; + final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase; /// Loads the staff member's profile. /// @@ -27,7 +38,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - final profile = await _getProfileUseCase(); + final Staff profile = await _getProfileUseCase(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); }, onError: @@ -63,5 +74,61 @@ class ProfileCubit extends Cubit }, ); } + + /// Loads personal information completion status. + Future loadPersonalInfoCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getPersonalInfoCompletionUseCase(); + emit(state.copyWith(personalInfoComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(personalInfoComplete: false); + }, + ); + } + + /// Loads emergency contacts completion status. + Future loadEmergencyContactsCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getEmergencyContactsCompletionUseCase(); + emit(state.copyWith(emergencyContactsComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(emergencyContactsComplete: false); + }, + ); + } + + /// Loads experience completion status. + Future loadExperienceCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getExperienceCompletionUseCase(); + emit(state.copyWith(experienceComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(experienceComplete: false); + }, + ); + } + + /// Loads tax forms completion status. + Future loadTaxFormsCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getTaxFormsCompletionUseCase(); + emit(state.copyWith(taxFormsComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(taxFormsComplete: false); + }, + ); + } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart index 05668656..39994b97 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -24,6 +24,16 @@ enum ProfileStatus { /// Contains the current profile data and loading status. /// Uses the [Staff] entity directly from domain layer. class ProfileState extends Equatable { + + const ProfileState({ + this.status = ProfileStatus.initial, + this.profile, + this.errorMessage, + this.personalInfoComplete, + this.emergencyContactsComplete, + this.experienceComplete, + this.taxFormsComplete, + }); /// Current status of the profile feature final ProfileStatus status; @@ -32,26 +42,48 @@ class ProfileState extends Equatable { /// Error message if status is error final String? errorMessage; - - const ProfileState({ - this.status = ProfileStatus.initial, - this.profile, - this.errorMessage, - }); + + /// Whether personal information is complete + final bool? personalInfoComplete; + + /// Whether emergency contacts are complete + final bool? emergencyContactsComplete; + + /// Whether experience information is complete + final bool? experienceComplete; + + /// Whether tax forms are complete + final bool? taxFormsComplete; /// Creates a copy of this state with updated values. ProfileState copyWith({ ProfileStatus? status, Staff? profile, String? errorMessage, + bool? personalInfoComplete, + bool? emergencyContactsComplete, + bool? experienceComplete, + bool? taxFormsComplete, }) { return ProfileState( status: status ?? this.status, profile: profile ?? this.profile, errorMessage: errorMessage ?? this.errorMessage, + personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete, + emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete, + experienceComplete: experienceComplete ?? this.experienceComplete, + taxFormsComplete: taxFormsComplete ?? this.taxFormsComplete, ); } @override - List get props => [status, profile, errorMessage]; + List get props => [ + status, + profile, + errorMessage, + personalInfoComplete, + emergencyContactsComplete, + experienceComplete, + taxFormsComplete, + ]; } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index f16beaec..49767da9 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -8,14 +8,11 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; import '../widgets/logout_button.dart'; -import '../widgets/profile_header.dart'; -import '../widgets/profile_menu_grid.dart'; -import '../widgets/profile_menu_item.dart'; +import '../widgets/header/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; -import '../widgets/section_title.dart'; +import '../widgets/sections/index.dart'; /// The main Staff Profile page. /// @@ -28,201 +25,124 @@ class StaffProfilePage extends StatelessWidget { /// Creates a [StaffProfilePage]. const StaffProfilePage({super.key}); - String _mapStatusToLevel(StaffStatus status) { - switch (status) { - case StaffStatus.active: - case StaffStatus.verified: - return 'Krower I'; - case StaffStatus.pending: - case StaffStatus.completedProfile: - return 'Pending'; - default: - return 'New'; - } - } - - void _onSignOut(ProfileCubit cubit, ProfileState state) { - if (state.status != ProfileStatus.loading) { - cubit.signOut(); - } - } - @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.profile; final ProfileCubit cubit = Modular.get(); - // Load profile data on first build + // Load profile data on first build if not already loaded if (cubit.state.status == ProfileStatus.initial) { cubit.loadProfile(); } return Scaffold( - body: BlocConsumer( - bloc: cubit, - listener: (context, state) { - if (state.status == ProfileStatus.signedOut) { - Modular.to.toGetStartedPage(); - } else if (state.status == ProfileStatus.error && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (context, state) { - // Show loading spinner if status is loading + body: BlocProvider.value( + value: cubit, + child: BlocConsumer( + listener: (BuildContext context, ProfileState state) { + // Load completion statuses when profile loads successfully + if (state.status == ProfileStatus.loaded && + state.personalInfoComplete == null) { + cubit.loadPersonalInfoCompletion(); + cubit.loadEmergencyContactsCompletion(); + cubit.loadExperienceCompletion(); + cubit.loadTaxFormsCompletion(); + } + + if (state.status == ProfileStatus.signedOut) { + Modular.to.toGetStartedPage(); + } else if (state.status == ProfileStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ProfileState state) { + // Show loading spinner if status is loading if (state.status == ProfileStatus.loading) { return const Center(child: CircularProgressIndicator()); } if (state.status == ProfileStatus.error) { - return Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - textAlign: TextAlign.center, - style: UiTypography.body1r.copyWith( - color: UiColors.textSecondary, + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + textAlign: TextAlign.center, + style: UiTypography.body1r.copyWith( + color: UiColors.textSecondary, + ), ), ), + ); + } + + final Staff? profile = state.profile; + if (profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: UiConstants.space16), + child: Column( + children: [ + ProfileHeader( + fullName: profile.name, + photoUrl: profile.avatar, + ), + Transform.translate( + offset: const Offset(0, -UiConstants.space6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: [ + // Reliability Stats and Score + ReliabilityStatsCard( + totalShifts: profile.totalShifts, + averageRating: profile.averageRating, + onTimeRate: profile.onTimeRate, + noShowCount: profile.noShowCount, + cancellationCount: profile.cancellationCount, + ), + + // Reliability Score Bar + ReliabilityScoreBar( + reliabilityScore: profile.reliabilityScore, + ), + + // Ordered sections + const OnboardingSection(), + + // Compliance section + const ComplianceSection(), + + // Finance section + const FinanceSection(), + + // Support section + const SupportSection(), + + // Logout button at the bottom + const LogoutButton(), + + const SizedBox(height: UiConstants.space6), + ], + ), + ), + ), + ], ), ); - } - - final profile = state.profile; - if (profile == null) { - return const Center(child: CircularProgressIndicator()); - } - - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: UiConstants.space16), - child: Column( - children: [ - ProfileHeader( - fullName: profile.name, - level: _mapStatusToLevel(profile.status), - photoUrl: profile.avatar, - onSignOutTap: () => _onSignOut(cubit, state), - ), - Transform.translate( - offset: const Offset(0, -UiConstants.space6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - ReliabilityStatsCard( - totalShifts: profile.totalShifts, - averageRating: profile.averageRating, - onTimeRate: profile.onTimeRate, - noShowCount: profile.noShowCount, - cancellationCount: profile.cancellationCount, - ), - const SizedBox(height: UiConstants.space6), - ReliabilityScoreBar( - reliabilityScore: profile.reliabilityScore, - ), - const SizedBox(height: UiConstants.space6), - SectionTitle(i18n.sections.onboarding), - ProfileMenuGrid( - crossAxisCount: 3, - - children: [ - ProfileMenuItem( - icon: UiIcons.user, - label: i18n.menu_items.personal_info, - onTap: () => Modular.to.toPersonalInfo(), - ), - ProfileMenuItem( - icon: UiIcons.phone, - label: i18n.menu_items.emergency_contact, - onTap: () => Modular.to.toEmergencyContact(), - ), - ProfileMenuItem( - icon: UiIcons.briefcase, - label: i18n.menu_items.experience, - onTap: () => Modular.to.toExperience(), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(i18n.sections.compliance), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.file, - label: i18n.menu_items.tax_forms, - onTap: () => Modular.to.toTaxForms(), - ), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space6), - SectionTitle(i18n.sections.finance), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.building, - label: i18n.menu_items.bank_account, - onTap: () => Modular.to.toBankAccount(), - ), - ProfileMenuItem( - icon: UiIcons.creditCard, - label: i18n.menu_items.payments, - onTap: () => Modular.to.toPayments(), - ), - ProfileMenuItem( - icon: UiIcons.clock, - label: i18n.menu_items.timecard, - onTap: () => Modular.to.toTimeCard(), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - SectionTitle( - i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings", - ), - ProfileMenuGrid( - crossAxisCount: 3, - children: [ - ProfileMenuItem( - icon: UiIcons.globe, - label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language", - onTap: () { - showModalBottomSheet( - context: context, - builder: (context) => const LanguageSelectorBottomSheet(), - ); - }, - ), - ], - ), - const SizedBox(height: UiConstants.space6), - LogoutButton( - onTap: () => _onSignOut(cubit, state), - ), - const SizedBox(height: UiConstants.space12), - ], - ), - ), - ), - ], - ), - ); - }, + }, + ), ), ); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart new file mode 100644 index 00000000..33eead3a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart @@ -0,0 +1,116 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'profile_level_badge.dart'; + +/// The header section of the staff profile page, containing avatar, name, and level. +/// +/// Uses design system tokens for all colors, typography, and spacing. +class ProfileHeader extends StatelessWidget { + /// Creates a [ProfileHeader]. + const ProfileHeader({ + super.key, + required this.fullName, + this.photoUrl, + }); + + /// The staff member's full name + final String fullName; + + /// Optional photo URL for the avatar + final String? photoUrl; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space16, + ), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.space6), + ), + ), + child: SafeArea( + bottom: false, + child: Column( + children: [ + // Avatar Section + Container( + width: 112, + height: 112, + padding: const EdgeInsets.all(UiConstants.space1), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + UiColors.accent, + UiColors.accent.withValues(alpha: 0.5), + UiColors.primaryForeground, + ], + ), + boxShadow: [ + BoxShadow( + color: UiColors.foreground.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: UiColors.primaryForeground.withValues(alpha: 0.2), + width: 4, + ), + ), + child: CircleAvatar( + backgroundColor: UiColors.background, + backgroundImage: photoUrl != null + ? NetworkImage(photoUrl!) + : null, + child: photoUrl == null + ? Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + UiColors.accent, + UiColors.accent.withValues(alpha: 0.7), + ], + ), + ), + alignment: Alignment.center, + child: Text( + fullName.isNotEmpty + ? fullName[0].toUpperCase() + : 'K', + style: UiTypography.displayM.primary, + ), + ) + : null, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + Text(fullName, style: UiTypography.headline2m.white), + const SizedBox(height: UiConstants.space1), + const ProfileLevelBadge(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart new file mode 100644 index 00000000..3661e192 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../blocs/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; + +/// A widget that displays the staff member's level badge. +/// +/// The level is calculated based on the staff status from ProfileCubit and displayed +/// in a styled container with the design system tokens. +class ProfileLevelBadge extends StatelessWidget { + /// Creates a [ProfileLevelBadge]. + const ProfileLevelBadge({super.key}); + + /// Maps staff status to a user-friendly level string. + String _mapStatusToLevel(StaffStatus status) { + switch (status) { + case StaffStatus.active: + case StaffStatus.verified: + return 'Krower I'; + case StaffStatus.pending: + case StaffStatus.completedProfile: + return 'Pending'; + default: + return 'New'; + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + final Staff? profile = state.profile; + if (profile == null) { + return const SizedBox.shrink(); + } + + final String level = _mapStatusToLevel(profile.status); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.accent.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(UiConstants.space5), + ), + child: Text(level, style: UiTypography.footnote1b.accent), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart index d703b41b..0673ba63 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart @@ -1,7 +1,6 @@ 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'; /// A bottom sheet that allows the user to select their preferred language. @@ -15,8 +14,8 @@ class LanguageSelectorBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: const BoxDecoration( color: UiColors.background, borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.radiusBase)), ), @@ -24,25 +23,25 @@ class LanguageSelectorBottomSheet extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + children: [ Text( t.settings.change_language, style: UiTypography.headline4m, textAlign: TextAlign.center, ), - SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space6), _buildLanguageOption( context, label: 'English', locale: AppLocale.en, ), - SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space4), _buildLanguageOption( context, label: 'Espaรฑol', locale: AppLocale.es, ), - SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space6), ], ), ), @@ -73,7 +72,7 @@ class LanguageSelectorBottomSheet extends StatelessWidget { }, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), child: Container( - padding: EdgeInsets.symmetric( + padding: const EdgeInsets.symmetric( vertical: UiConstants.space4, horizontal: UiConstants.space4, ), @@ -87,7 +86,7 @@ class LanguageSelectorBottomSheet extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Text( label, style: isSelected @@ -95,7 +94,7 @@ class LanguageSelectorBottomSheet extends StatelessWidget { : UiTypography.body1r, ), if (isSelected) - Icon( + const Icon( UiIcons.check, color: UiColors.primary, size: 24.0, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart index 3a2499c6..d74e9655 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart @@ -1,47 +1,73 @@ 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 '../blocs/profile_cubit.dart'; +import '../blocs/profile_state.dart'; /// The sign-out button widget. /// /// Uses design system tokens for all colors, typography, spacing, and icons. +/// Handles logout logic when tapped and navigates to onboarding on success. class LogoutButton extends StatelessWidget { - final VoidCallback onTap; + const LogoutButton({super.key}); - const LogoutButton({super.key, required this.onTap}); + /// Handles the sign-out action. + /// + /// Checks if the profile is not currently loading, then triggers the + /// sign-out process via the ProfileCubit. + void _handleSignOut(BuildContext context, ProfileState state) { + if (state.status != ProfileStatus.loading) { + context.read().signOut(); + } + } @override Widget build(BuildContext context) { - final i18n = t.staff.profile.header; + final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header; - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Material( - color: UiColors.transparent, - child: InkWell( - onTap: onTap, + return BlocListener( + listener: (BuildContext context, ProfileState state) { + if (state.status == ProfileStatus.signedOut) { + // Navigate to get started page after successful sign-out + // This will be handled by the profile page listener + } + }, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.logOut, - color: UiColors.destructive, - size: 20, - ), - const SizedBox(width: UiConstants.space2), - Text( - i18n.sign_out, - style: UiTypography.body1m.textError, - ), - ], + border: Border.all(color: UiColors.border), + ), + child: Material( + color: UiColors.transparent, + child: InkWell( + onTap: () { + _handleSignOut( + context, + context.read().state, + ); + }, + borderRadius: UiConstants.radiusLg, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.logOut, + color: UiColors.destructive, + size: 20, + ), + const SizedBox(width: UiConstants.space2), + Text( + i18n.sign_out, + style: UiTypography.body1m.textError, + ), + ], + ), ), ), ), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart deleted file mode 100644 index bee90690..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; - -/// The header section of the staff profile page, containing avatar, name, level, -/// and a sign-out button. -/// -/// Uses design system tokens for all colors, typography, and spacing. -class ProfileHeader extends StatelessWidget { - /// The staff member's full name - final String fullName; - - /// The staff member's level (e.g., "Krower I") - final String level; - - /// Optional photo URL for the avatar - final String? photoUrl; - - /// Callback when sign out is tapped - final VoidCallback onSignOutTap; - - /// Creates a [ProfileHeader]. - const ProfileHeader({ - super.key, - required this.fullName, - required this.level, - this.photoUrl, - required this.onSignOutTap, - }); - - @override - Widget build(BuildContext context) { - final i18n = t.staff.profile.header; - - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space5, - UiConstants.space5, - UiConstants.space16, - ), - decoration: const BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(UiConstants.space6), - ), - ), - child: SafeArea( - bottom: false, - child: Column( - children: [ - // Top Bar - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - i18n.title, - style: UiTypography.headline4m.textSecondary, - ), - GestureDetector( - onTap: onSignOutTap, - child: Text( - i18n.sign_out, - style: UiTypography.body2m.copyWith( - color: UiColors.primaryForeground.withValues(alpha: 0.8), - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space8), - // Avatar Section - Stack( - alignment: Alignment.bottomRight, - children: [ - Container( - width: 112, - height: 112, - padding: const EdgeInsets.all(UiConstants.space1), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - UiColors.accent, - UiColors.accent.withValues(alpha: 0.5), - UiColors.primaryForeground, - ], - ), - boxShadow: [ - BoxShadow( - color: UiColors.foreground.withValues(alpha: 0.2), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: UiColors.primaryForeground.withValues(alpha: 0.2), - width: 4, - ), - ), - child: CircleAvatar( - backgroundColor: UiColors.background, - backgroundImage: photoUrl != null - ? NetworkImage(photoUrl!) - : null, - child: photoUrl == null - ? Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - UiColors.accent, - UiColors.accent.withValues(alpha: 0.7), - ], - ), - ), - alignment: Alignment.center, - child: Text( - fullName.isNotEmpty - ? fullName[0].toUpperCase() - : 'K', - style: UiTypography.displayM.primary, - ), - ) - : null, - ), - ), - ), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: UiColors.primaryForeground, - shape: BoxShape.circle, - border: Border.all(color: UiColors.primary, width: 2), - boxShadow: [ - BoxShadow( - color: UiColors.foreground.withValues(alpha: 0.1), - blurRadius: 4, - ), - ], - ), - child: const Icon( - UiIcons.camera, - size: 16, - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - Text( - fullName, - style: UiTypography.headline3m.textPlaceholder, - ), - const SizedBox(height: UiConstants.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.accent.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(UiConstants.space5), - ), - child: Text( - level, - style: UiTypography.footnote1b.accent, - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart index ad00b1eb..933f8582 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart @@ -4,14 +4,14 @@ import 'package:design_system/design_system.dart'; /// Lays out a list of widgets (intended for [ProfileMenuItem]s) in a responsive grid. /// It uses [Wrap] and manually calculates item width based on the screen size. class ProfileMenuGrid extends StatelessWidget { - final int crossAxisCount; - final List children; const ProfileMenuGrid({ super.key, required this.children, this.crossAxisCount = 2, }); + final int crossAxisCount; + final List children; @override Widget build(BuildContext context) { @@ -19,17 +19,17 @@ class ProfileMenuGrid extends StatelessWidget { const double spacing = UiConstants.space3; return LayoutBuilder( - builder: (context, constraints) { - final totalWidth = constraints.maxWidth; - final totalSpacingWidth = spacing * (crossAxisCount - 1); - final itemWidth = (totalWidth - totalSpacingWidth) / crossAxisCount; + builder: (BuildContext context, BoxConstraints constraints) { + final double totalWidth = constraints.maxWidth; + final double totalSpacingWidth = spacing * (crossAxisCount - 1); + final double itemWidth = (totalWidth - totalSpacingWidth) / crossAxisCount; return Wrap( spacing: spacing, runSpacing: spacing, alignment: WrapAlignment.start, crossAxisAlignment: WrapCrossAlignment.start, - children: children.map((child) { + children: children.map((Widget child) { return SizedBox( width: itemWidth, child: child, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart index d61fac6f..76f2b30b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart @@ -1,15 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; /// An individual item within the profile menu grid. /// /// Uses design system tokens for all colors, typography, spacing, and borders. class ProfileMenuItem extends StatelessWidget { - final IconData icon; - final String label; - final bool? completed; - final VoidCallback? onTap; - const ProfileMenuItem({ super.key, required this.icon, @@ -18,6 +13,11 @@ class ProfileMenuItem extends StatelessWidget { this.onTap, }); + final IconData icon; + final String label; + final bool? completed; + final VoidCallback? onTap; + @override Widget build(BuildContext context) { return GestureDetector( @@ -32,12 +32,12 @@ class ProfileMenuItem extends StatelessWidget { child: AspectRatio( aspectRatio: 1.0, child: Stack( - children: [ + children: [ Align( alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Container( width: 36, height: 36, @@ -73,21 +73,22 @@ class ProfileMenuItem extends StatelessWidget { height: 16, decoration: BoxDecoration( shape: BoxShape.circle, + border: Border.all( + color: completed! ? UiColors.primary : UiColors.error, + width: 0.5, + ), color: completed! - ? UiColors.primary - : UiColors.primary.withValues(alpha: 0.1), + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.error.withValues(alpha: 0.15), ), alignment: Alignment.center, child: completed! ? const Icon( UiIcons.check, size: 10, - color: UiColors.primaryForeground, + color: UiColors.primary, ) - : Text( - "!", - style: UiTypography.footnote2b.primary, - ), + : Text("!", style: UiTypography.footnote2b.textError), ), ), ], diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart index 82c0e4ea..9f0908fe 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart @@ -6,17 +6,17 @@ import 'package:design_system/design_system.dart'; /// /// Uses design system tokens for all colors, typography, and spacing. class ReliabilityScoreBar extends StatelessWidget { - final int? reliabilityScore; const ReliabilityScoreBar({ super.key, this.reliabilityScore, }); + final int? reliabilityScore; @override Widget build(BuildContext context) { - final i18n = t.staff.profile.reliability_score; - final score = (reliabilityScore ?? 0) / 100; + final TranslationsStaffProfileReliabilityScoreEn i18n = t.staff.profile.reliability_score; + final double score = (reliabilityScore ?? 0) / 100; return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -26,10 +26,10 @@ class ReliabilityScoreBar extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Text( i18n.title, style: UiTypography.body2m.primary, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart index 52781dad..f59e5838 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart @@ -5,11 +5,6 @@ import 'package:flutter/material.dart'; /// /// Uses design system tokens for all colors, typography, spacing, and icons. class ReliabilityStatsCard extends StatelessWidget { - final int? totalShifts; - final double? averageRating; - final int? onTimeRate; - final int? noShowCount; - final int? cancellationCount; const ReliabilityStatsCard({ super.key, @@ -19,6 +14,11 @@ class ReliabilityStatsCard extends StatelessWidget { this.noShowCount, this.cancellationCount, }); + final int? totalShifts; + final double? averageRating; + final int? onTimeRate; + final int? noShowCount; + final int? cancellationCount; @override Widget build(BuildContext context) { @@ -28,7 +28,7 @@ class ReliabilityStatsCard extends StatelessWidget { color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border), - boxShadow: [ + boxShadow: [ BoxShadow( color: UiColors.foreground.withValues(alpha: 0.05), blurRadius: 4, @@ -38,7 +38,7 @@ class ReliabilityStatsCard extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ _buildStatItem( context, UiIcons.briefcase, @@ -82,7 +82,7 @@ class ReliabilityStatsCard extends StatelessWidget { ) { return Expanded( child: Column( - children: [ + children: [ Container( width: UiConstants.space10, height: UiConstants.space10, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart index 3cd0c9e0..5542d7ef 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart @@ -5,9 +5,9 @@ import 'package:design_system/design_system.dart'; /// /// Uses design system tokens for typography, colors, and spacing. class SectionTitle extends StatelessWidget { - final String title; const SectionTitle(this.title, {super.key}); + final String title; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart new file mode 100644 index 00000000..11d303df --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart @@ -0,0 +1,48 @@ +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/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the compliance section of the staff profile. +/// +/// This section contains menu items for tax forms and other compliance-related documents. +/// Displays completion status for each item. +class ComplianceSection extends StatelessWidget { + /// Creates a [ComplianceSection]. + const ComplianceSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(i18n.sections.compliance), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.file, + label: i18n.menu_items.tax_forms, + completed: state.taxFormsComplete, + onTap: () => Modular.to.toTaxForms(), + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart new file mode 100644 index 00000000..73db7355 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/finance_section.dart @@ -0,0 +1,48 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the finance section of the staff profile. +/// +/// This section contains menu items for bank account, payments, and timecard information. +class FinanceSection extends StatelessWidget { + /// Creates a [FinanceSection]. + const FinanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + + return Column( + children: [ + SectionTitle(i18n.sections.finance), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.building, + label: i18n.menu_items.bank_account, + onTap: () => Modular.to.toBankAccount(), + ), + ProfileMenuItem( + icon: UiIcons.creditCard, + label: i18n.menu_items.payments, + onTap: () => Modular.to.toPayments(), + ), + ProfileMenuItem( + icon: UiIcons.clock, + label: i18n.menu_items.timecard, + onTap: () => Modular.to.toTimeCard(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart new file mode 100644 index 00000000..6295bcba --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart @@ -0,0 +1,5 @@ +export 'compliance_section.dart'; +export 'finance_section.dart'; +export 'onboarding_section.dart'; +export 'support_section.dart'; + diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart new file mode 100644 index 00000000..327e58ea --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -0,0 +1,66 @@ +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/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the onboarding section of the staff profile. +/// +/// This section contains menu items for personal information, emergency contact, +/// and work experience setup. Displays completion status for each item. +class OnboardingSection extends StatelessWidget { + /// Creates an [OnboardingSection]. + const OnboardingSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; + + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + return Column( + children: [ + SectionTitle(i18n.sections.onboarding), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.user, + label: i18n.menu_items.personal_info, + completed: state.personalInfoComplete, + onTap: () => Modular.to.toPersonalInfo(), + ), + ProfileMenuItem( + icon: UiIcons.phone, + label: i18n.menu_items.emergency_contact, + completed: state.emergencyContactsComplete, + onTap: () => Modular.to.toEmergencyContact(), + ), + ProfileMenuItem( + icon: UiIcons.briefcase, + label: i18n.menu_items.experience, + completed: state.experienceComplete, + onTap: () => Modular.to.toExperience(), + ), + ProfileMenuItem( + icon: UiIcons.shirt, + label: i18n.menu_items.attire, + onTap: () => Modular.to.toAttire(), + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart new file mode 100644 index 00000000..5fa0b4f5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/settings_section.dart @@ -0,0 +1,47 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../language_selector_bottom_sheet.dart'; +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the settings section of the staff profile. +/// +/// This section contains menu items for language selection. +class SettingsSection extends StatelessWidget { + /// Creates a [SettingsSection]. + const SettingsSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + SectionTitle(i18n.sections.settings), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.globe, + label: i18n.menu_items.language, + onTap: () { + showModalBottomSheet( + context: context, + builder: (BuildContext context) => + const LanguageSelectorBottomSheet(), + ); + }, + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart new file mode 100644 index 00000000..f547c340 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/support_section.dart @@ -0,0 +1,46 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../profile_menu_grid.dart'; +import '../profile_menu_item.dart'; +import '../section_title.dart'; + +/// Widget displaying the support section of the staff profile. +/// +/// This section contains menu items for FAQs and privacy & security settings. +class SupportSection extends StatelessWidget { + /// Creates a [SupportSection]. + const SupportSection({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(i18n.sections.support), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.helpCircle, + label: i18n.menu_items.faqs, + onTap: () => Modular.to.toFaqs(), + ), + ProfileMenuItem( + icon: UiIcons.shield, + label: i18n.menu_items.privacy_security, + onTap: () => Modular.to.toPrivacySecurity(), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index 88f56cc5..06b38c53 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; -import 'data/repositories/profile_repository_impl.dart'; -import 'domain/repositories/profile_repository.dart'; -import 'domain/usecases/get_profile_usecase.dart'; -import 'domain/usecases/sign_out_usecase.dart'; import 'presentation/blocs/profile_cubit.dart'; import 'presentation/pages/staff_profile_page.dart'; @@ -15,28 +12,56 @@ import 'presentation/pages/staff_profile_page.dart'; /// following Clean Architecture principles. /// /// Dependency flow: -/// - Repository implementation (ProfileRepositoryImpl) delegates to data_connect -/// - Use cases depend on repository interface +/// - Use cases from data_connect layer (StaffConnectorRepository) /// - Cubit depends on use cases class StaffProfileModule extends Module { @override void binds(Injector i) { - // Repository implementation - delegates to data_connect - i.addLazySingleton( - ProfileRepositoryImpl.new, + // StaffConnectorRepository intialization + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), ); - // Use cases - depend on repository interface - i.addLazySingleton( - () => GetProfileUseCase(i.get()), + // Use cases from data_connect - depend on StaffConnectorRepository + i.addLazySingleton( + () => + GetStaffProfileUseCase(repository: i.get()), ); - i.addLazySingleton( - () => SignOutUseCase(i.get()), + i.addLazySingleton( + () => SignOutStaffUseCase(repository: i.get()), + ); + i.addLazySingleton( + () => GetPersonalInfoCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetEmergencyContactsCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetExperienceCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetTaxFormsCompletionUseCase( + repository: i.get(), + ), ); - // Presentation layer - Cubit depends on use cases - i.add( - () => ProfileCubit(i.get(), i.get()), + // Presentation layer - Cubit as singleton to avoid recreation + // BlocProvider will use this same instance, preventing state emission after close + i.addSingleton( + () => ProfileCubit( + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index dfb7e44e..afbb94c5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -1,3 +1,4 @@ +import 'package:firebase_data_connect/src/core/ref.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart' as domain; @@ -10,11 +11,11 @@ import '../../domain/repositories/certificates_repository.dart'; /// It maps raw generated data types to clean [domain.StaffDocument] entities. class CertificatesRepositoryImpl implements CertificatesRepository { - /// The Data Connect service instance. - final DataConnectService _service; /// Creates a [CertificatesRepositoryImpl]. CertificatesRepositoryImpl() : _service = DataConnectService.instance; + /// The Data Connect service instance. + final DataConnectService _service; @override Future> getCertificates() async { @@ -22,7 +23,7 @@ class CertificatesRepositoryImpl final String staffId = await _service.getStaffId(); // Execute the query via DataConnect generated SDK - final result = + final QueryResult result = await _service.connector .listStaffDocumentsByStaffId(staffId: staffId) .execute(); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart index e7f8f206..16e56d06 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart @@ -7,12 +7,12 @@ import '../repositories/certificates_repository.dart'; /// Delegates the data retrieval to the [CertificatesRepository]. /// Follows the strict one-to-one mapping between action and use case. class GetCertificatesUseCase extends NoInputUseCase> { - final CertificatesRepository _repository; /// Creates a [GetCertificatesUseCase]. /// /// Requires a [CertificatesRepository] to access the certificates data source. GetCertificatesUseCase(this._repository); + final CertificatesRepository _repository; @override Future> call() { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart index e42bbea1..49bbb5f8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart @@ -6,12 +6,12 @@ import 'certificates_state.dart'; class CertificatesCubit extends Cubit with BlocErrorHandler { - final GetCertificatesUseCase _getCertificatesUseCase; CertificatesCubit(this._getCertificatesUseCase) : super(const CertificatesState()) { loadCertificates(); } + final GetCertificatesUseCase _getCertificatesUseCase; Future loadCertificates() async { emit(state.copyWith(status: CertificatesStatus.loading)); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart index 912b6ae9..76992e62 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart @@ -4,15 +4,15 @@ import 'package:krow_domain/krow_domain.dart'; enum CertificatesStatus { initial, loading, success, failure } class CertificatesState extends Equatable { - final CertificatesStatus status; - final List certificates; - final String? errorMessage; const CertificatesState({ this.status = CertificatesStatus.initial, List? certificates, this.errorMessage, }) : certificates = certificates ?? const []; + final CertificatesStatus status; + final List certificates; + final String? errorMessage; CertificatesState copyWith({ CertificatesStatus? status, @@ -27,11 +27,11 @@ class CertificatesState extends Equatable { } @override - List get props => [status, certificates, errorMessage]; + List get props => [status, certificates, errorMessage]; /// The number of verified certificates. int get completedCount => certificates - .where((doc) => doc.status == DocumentStatus.verified) + .where((StaffDocument doc) => doc.status == DocumentStatus.verified) .length; /// The total number of certificates. diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/add_certificate_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/add_certificate_card.dart index 8e0634a1..bf8f26f7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/add_certificate_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/add_certificate_card.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; class AddCertificateCard extends StatelessWidget { - final VoidCallback onTap; const AddCertificateCard({super.key, required this.onTap}); + final VoidCallback onTap; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart index 1e5f8c35..491f4f43 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart @@ -5,11 +5,6 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; class CertificateCard extends StatelessWidget { - final StaffDocument document; - final VoidCallback? onUpload; - final VoidCallback? onEditExpiry; - final VoidCallback? onRemove; - final VoidCallback? onView; const CertificateCard({ super.key, @@ -19,6 +14,11 @@ class CertificateCard extends StatelessWidget { this.onRemove, this.onView, }); + final StaffDocument document; + final VoidCallback? onUpload; + final VoidCallback? onEditExpiry; + final VoidCallback? onRemove; + final VoidCallback? onView; @override Widget build(BuildContext context) { @@ -412,7 +412,7 @@ class CertificateCard extends StatelessWidget { } class _CertificateUiProps { + _CertificateUiProps(this.icon, this.color); final IconData icon; final Color color; - _CertificateUiProps(this.icon, this.color); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart index 5651d6af..52b576a9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart @@ -4,6 +4,13 @@ import 'package:flutter/material.dart'; /// Modal for uploading or editing a certificate expiry. class CertificateUploadModal extends StatelessWidget { + + const CertificateUploadModal({ + super.key, + this.document, + required this.onSave, + required this.onCancel, + }); /// The document being edited, or null for a new upload. // ignore: unused_field final dynamic @@ -13,13 +20,6 @@ class CertificateUploadModal extends StatelessWidget { final VoidCallback onSave; final VoidCallback onCancel; - const CertificateUploadModal({ - super.key, - this.document, - required this.onSave, - required this.onCancel, - }); - @override Widget build(BuildContext context) { return Container( @@ -100,7 +100,7 @@ class CertificateUploadModal extends StatelessWidget { children: [ Container( padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: UiColors.tagActive, shape: BoxShape.circle, ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart index d2d8428d..121cb8b6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart @@ -4,14 +4,14 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:core_localization/core_localization.dart'; class CertificatesHeader extends StatelessWidget { - final int completedCount; - final int totalCount; const CertificatesHeader({ super.key, required this.completedCount, required this.totalCount, }); + final int completedCount; + final int totalCount; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/staff_certificates.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/staff_certificates.dart index 86a9d8d2..92e678f0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/staff_certificates.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/staff_certificates.dart @@ -1,3 +1,3 @@ -library staff_certificates; +library; export 'src/staff_certificates_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index b72458e7..8b9cdcd4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart' as domain; @@ -7,21 +8,21 @@ import '../../domain/repositories/documents_repository.dart'; /// Implementation of [DocumentsRepository] using Data Connect. class DocumentsRepositoryImpl implements DocumentsRepository { - final DataConnectService _service; DocumentsRepositoryImpl() : _service = DataConnectService.instance; + final DataConnectService _service; @override Future> getDocuments() async { return _service.run(() async { - final String? staffId = await _service.getStaffId(); + final String staffId = await _service.getStaffId(); /// MOCK IMPLEMENTATION /// To be replaced with real data connect query when available - return [ + return [ domain.StaffDocument( id: 'doc1', - staffId: staffId!, + staffId: staffId, documentId: 'd1', name: 'Work Permit', description: 'Valid work permit document', @@ -31,7 +32,7 @@ class DocumentsRepositoryImpl ), domain.StaffDocument( id: 'doc2', - staffId: staffId!, + staffId: staffId, documentId: 'd2', name: 'Health and Safety Training', description: 'Certificate of completion for health and safety training', @@ -79,3 +80,5 @@ class DocumentsRepositoryImpl return domain.DocumentStatus.pending; } } + + diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart index 0ee6c731..8b780f48 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart @@ -6,9 +6,9 @@ import '../repositories/documents_repository.dart'; /// /// Delegates to [DocumentsRepository]. class GetDocumentsUseCase implements NoInputUseCase> { - final DocumentsRepository _repository; GetDocumentsUseCase(this._repository); + final DocumentsRepository _repository; @override Future> call() { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart index dd4704dd..f0cccda8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart @@ -6,9 +6,9 @@ import 'documents_state.dart'; class DocumentsCubit extends Cubit with BlocErrorHandler { - final GetDocumentsUseCase _getDocumentsUseCase; DocumentsCubit(this._getDocumentsUseCase) : super(const DocumentsState()); + final GetDocumentsUseCase _getDocumentsUseCase; Future loadDocuments() async { emit(state.copyWith(status: DocumentsStatus.loading)); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart index db7bcfe2..27c8676d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart @@ -4,15 +4,15 @@ import 'package:krow_domain/krow_domain.dart'; enum DocumentsStatus { initial, loading, success, failure } class DocumentsState extends Equatable { - final DocumentsStatus status; - final List documents; - final String? errorMessage; const DocumentsState({ this.status = DocumentsStatus.initial, List? documents, this.errorMessage, }) : documents = documents ?? const []; + final DocumentsStatus status; + final List documents; + final String? errorMessage; DocumentsState copyWith({ DocumentsStatus? status, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index b1633644..dbb95c1c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -8,11 +8,12 @@ import 'package:core_localization/core_localization.dart'; import '../blocs/documents/documents_cubit.dart'; import '../blocs/documents/documents_state.dart'; -import 'package:krow_core/core.dart'; import '../widgets/document_card.dart'; import '../widgets/documents_progress_card.dart'; class DocumentsPage extends StatelessWidget { + const DocumentsPage({super.key}); + @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart index ff64a72f..46b06131 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart @@ -5,14 +5,14 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:core_localization/core_localization.dart'; class DocumentCard extends StatelessWidget { - final StaffDocument document; - final VoidCallback? onTap; const DocumentCard({ super.key, required this.document, this.onTap, }); + final StaffDocument document; + final VoidCallback? onTap; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_progress_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_progress_card.dart index de2fc2c2..91888fa1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_progress_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_progress_card.dart @@ -5,6 +5,13 @@ import 'package:core_localization/core_localization.dart'; /// A card displaying the overall verification progress of documents. class DocumentsProgressCard extends StatelessWidget { + + const DocumentsProgressCard({ + super.key, + required this.completedCount, + required this.totalCount, + required this.progress, + }); /// The number of verified documents. final int completedCount; @@ -14,13 +21,6 @@ class DocumentsProgressCard extends StatelessWidget { /// The progress ratio (0.0 to 1.0). final double progress; - const DocumentsProgressCard({ - super.key, - required this.completedCount, - required this.totalCount, - required this.progress, - }); - @override Widget build(BuildContext context) { return Container( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart index d1fcd11a..8193497e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart @@ -18,7 +18,7 @@ class StaffDocumentsModule extends Module { void routes(RouteManager r) { r.child( StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents), - child: (_) => DocumentsPage(), + child: (_) => const DocumentsPage(), ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/staff_documents.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/staff_documents.dart index e380e3b8..88226900 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/staff_documents.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/staff_documents.dart @@ -1,3 +1,3 @@ -library staff_documents; +library; export 'src/staff_documents_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart index 973cb983..015c1d14 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart @@ -6,7 +6,7 @@ import 'package:krow_domain/krow_domain.dart'; class TaxFormMapper { static TaxForm fromDataConnect(dc.GetTaxFormsByStaffIdTaxForms form) { // Construct the legacy map for the entity - final Map formData = { + final Map formData = { 'firstName': form.firstName, 'lastName': form.lastName, 'middleInitial': form.mInitial, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart index c834f02f..73de4e89 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart @@ -17,7 +17,7 @@ class TaxFormsRepositoryImpl Future> getTaxForms() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - final response = await _service.connector + final QueryResult response = await _service.connector .getTaxFormsByStaffId(staffId: staffId) .execute(); @@ -39,7 +39,7 @@ class TaxFormsRepositoryImpl } if (createdNew) { - final response2 = + final QueryResult response2 = await _service.connector.getTaxFormsByStaffId(staffId: staffId).execute(); return response2.data.taxForms .map(TaxFormMapper.fromDataConnect) @@ -115,14 +115,18 @@ class TaxFormsRepositoryImpl void _mapCommonFields( dc.UpdateTaxFormVariablesBuilder builder, Map data) { - if (data.containsKey('firstName')) + if (data.containsKey('firstName')) { builder.firstName(data['firstName'] as String?); - if (data.containsKey('lastName')) + } + if (data.containsKey('lastName')) { builder.lastName(data['lastName'] as String?); - if (data.containsKey('middleInitial')) + } + if (data.containsKey('middleInitial')) { builder.mInitial(data['middleInitial'] as String?); - if (data.containsKey('otherLastNames')) + } + if (data.containsKey('otherLastNames')) { builder.oLastName(data['otherLastNames'] as String?); + } if (data.containsKey('dob')) { final String dob = data['dob'] as String; // Handle both ISO string and MM/dd/yyyy manual entry @@ -155,14 +159,17 @@ class TaxFormsRepositoryImpl } if (data.containsKey('email')) builder.email(data['email'] as String?); if (data.containsKey('phone')) builder.phone(data['phone'] as String?); - if (data.containsKey('address')) + if (data.containsKey('address')) { builder.address(data['address'] as String?); - if (data.containsKey('aptNumber')) + } + if (data.containsKey('aptNumber')) { builder.apt(data['aptNumber'] as String?); + } if (data.containsKey('city')) builder.city(data['city'] as String?); if (data.containsKey('state')) builder.state(data['state'] as String?); - if (data.containsKey('zipCode')) + if (data.containsKey('zipCode')) { builder.zipCode(data['zipCode'] as String?); + } } void _mapI9Fields( @@ -176,16 +183,21 @@ class TaxFormsRepositoryImpl dc.CitizenshipStatus.values.byName(status.toUpperCase())); } catch (_) {} } - if (data.containsKey('uscisNumber')) + if (data.containsKey('uscisNumber')) { builder.uscis(data['uscisNumber'] as String?); - if (data.containsKey('passportNumber')) + } + if (data.containsKey('passportNumber')) { builder.passportNumber(data['passportNumber'] as String?); - if (data.containsKey('countryIssuance')) + } + if (data.containsKey('countryIssuance')) { builder.countryIssue(data['countryIssuance'] as String?); - if (data.containsKey('preparerUsed')) + } + if (data.containsKey('preparerUsed')) { builder.prepartorOrTranslator(data['preparerUsed'] as bool?); - if (data.containsKey('signature')) + } + if (data.containsKey('signature')) { builder.signature(data['signature'] as String?); + } // Note: admissionNumber not in builder based on file read } @@ -208,19 +220,23 @@ class TaxFormsRepositoryImpl try { final String status = data['filingStatus'] as String; // Simple mapping assumptions: - if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE); - else if (status.contains('married')) + if (status.contains('single')) { + builder.marital(dc.MaritalStatus.SINGLE); + } else if (status.contains('married')) builder.marital(dc.MaritalStatus.MARRIED); else if (status.contains('head')) builder.marital(dc.MaritalStatus.HEAD); } catch (_) {} } - if (data.containsKey('multipleJobs')) + if (data.containsKey('multipleJobs')) { builder.multipleJob(data['multipleJobs'] as bool?); - if (data.containsKey('qualifyingChildren')) + } + if (data.containsKey('qualifyingChildren')) { builder.childrens(data['qualifyingChildren'] as int?); - if (data.containsKey('otherDependents')) + } + if (data.containsKey('otherDependents')) { builder.otherDeps(data['otherDependents'] as int?); + } if (data.containsKey('otherIncome')) { builder.otherInconme(double.tryParse(data['otherIncome'].toString())); } @@ -231,8 +247,9 @@ class TaxFormsRepositoryImpl builder.extraWithholding( double.tryParse(data['extraWithholding'].toString())); } - if (data.containsKey('signature')) + if (data.containsKey('signature')) { builder.signature(data['signature'] as String?); + } } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/get_tax_forms_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/get_tax_forms_usecase.dart index 2e203594..e7c021c4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/get_tax_forms_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/get_tax_forms_usecase.dart @@ -2,9 +2,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/tax_forms_repository.dart'; class GetTaxFormsUseCase { - final TaxFormsRepository _repository; GetTaxFormsUseCase(this._repository); + final TaxFormsRepository _repository; Future> call() async { return _repository.getTaxForms(); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart index 09c52e27..ca8810d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart @@ -2,9 +2,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/tax_forms_repository.dart'; class SaveI9FormUseCase { - final TaxFormsRepository _repository; SaveI9FormUseCase(this._repository); + final TaxFormsRepository _repository; Future call(I9TaxForm form) async { return _repository.updateI9Form(form); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart index 995e090a..06848894 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart @@ -2,9 +2,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/tax_forms_repository.dart'; class SaveW4FormUseCase { - final TaxFormsRepository _repository; SaveW4FormUseCase(this._repository); + final TaxFormsRepository _repository; Future call(W4TaxForm form) async { return _repository.updateW4Form(form); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart index b57370c7..240c7e05 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart @@ -2,9 +2,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/tax_forms_repository.dart'; class SubmitI9FormUseCase { - final TaxFormsRepository _repository; SubmitI9FormUseCase(this._repository); + final TaxFormsRepository _repository; Future call(I9TaxForm form) async { return _repository.submitI9Form(form); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart index d4170855..7c92f441 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart @@ -2,9 +2,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/tax_forms_repository.dart'; class SubmitW4FormUseCase { - final TaxFormsRepository _repository; SubmitW4FormUseCase(this._repository); + final TaxFormsRepository _repository; Future call(W4TaxForm form) async { return _repository.submitW4Form(form); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart index 1567d7e5..d9c7a8a6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart @@ -7,10 +7,10 @@ import '../../../domain/usecases/submit_i9_form_usecase.dart'; import 'form_i9_state.dart'; class FormI9Cubit extends Cubit with BlocErrorHandler { - final SubmitI9FormUseCase _submitI9FormUseCase; - String _formId = ''; FormI9Cubit(this._submitI9FormUseCase) : super(const FormI9State()); + final SubmitI9FormUseCase _submitI9FormUseCase; + String _formId = ''; void initialize(TaxForm? form) { if (form == null || form.formData.isEmpty) { @@ -99,7 +99,7 @@ class FormI9Cubit extends Cubit with BlocErrorHandler await handleError( emit: emit, action: () async { - final Map formData = { + final Map formData = { 'firstName': state.firstName, 'lastName': state.lastName, 'middleInitial': state.middleInitial, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_state.dart index 9fd739aa..e18268a3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_state.dart @@ -3,6 +3,32 @@ import 'package:equatable/equatable.dart'; enum FormI9Status { initial, submitting, success, failure } class FormI9State extends Equatable { + + const FormI9State({ + this.currentStep = 0, + this.firstName = '', + this.lastName = '', + this.middleInitial = '', + this.otherLastNames = '', + this.dob = '', + this.ssn = '', + this.email = '', + this.phone = '', + this.address = '', + this.aptNumber = '', + this.city = '', + this.state = '', + this.zipCode = '', + this.citizenshipStatus = '', + this.uscisNumber = '', + this.admissionNumber = '', + this.passportNumber = '', + this.countryIssuance = '', + this.preparerUsed = false, + this.signature = '', + this.status = FormI9Status.initial, + this.errorMessage, + }); final int currentStep; // Personal Info final String firstName; @@ -35,32 +61,6 @@ class FormI9State extends Equatable { final FormI9Status status; final String? errorMessage; - const FormI9State({ - this.currentStep = 0, - this.firstName = '', - this.lastName = '', - this.middleInitial = '', - this.otherLastNames = '', - this.dob = '', - this.ssn = '', - this.email = '', - this.phone = '', - this.address = '', - this.aptNumber = '', - this.city = '', - this.state = '', - this.zipCode = '', - this.citizenshipStatus = '', - this.uscisNumber = '', - this.admissionNumber = '', - this.passportNumber = '', - this.countryIssuance = '', - this.preparerUsed = false, - this.signature = '', - this.status = FormI9Status.initial, - this.errorMessage, - }); - FormI9State copyWith({ int? currentStep, String? firstName, @@ -114,7 +114,7 @@ class FormI9State extends Equatable { } @override - List get props => [ + List get props => [ currentStep, firstName, lastName, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_cubit.dart index 4ccfb4ff..7ab972e0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_cubit.dart @@ -6,9 +6,9 @@ import 'tax_forms_state.dart'; class TaxFormsCubit extends Cubit with BlocErrorHandler { - final GetTaxFormsUseCase _getTaxFormsUseCase; TaxFormsCubit(this._getTaxFormsUseCase) : super(const TaxFormsState()); + final GetTaxFormsUseCase _getTaxFormsUseCase; Future loadTaxForms() async { emit(state.copyWith(status: TaxFormsStatus.loading)); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_state.dart index a117fda3..020a2f54 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/tax_forms/tax_forms_state.dart @@ -4,15 +4,15 @@ import 'package:krow_domain/krow_domain.dart'; enum TaxFormsStatus { initial, loading, success, failure } class TaxFormsState extends Equatable { - final TaxFormsStatus status; - final List forms; - final String? errorMessage; const TaxFormsState({ this.status = TaxFormsStatus.initial, this.forms = const [], this.errorMessage, }); + final TaxFormsStatus status; + final List forms; + final String? errorMessage; TaxFormsState copyWith({ TaxFormsStatus? status, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart index c6d02860..52e29b8a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart @@ -7,10 +7,10 @@ import '../../../domain/usecases/submit_w4_form_usecase.dart'; import 'form_w4_state.dart'; class FormW4Cubit extends Cubit with BlocErrorHandler { - final SubmitW4FormUseCase _submitW4FormUseCase; - String _formId = ''; FormW4Cubit(this._submitW4FormUseCase) : super(const FormW4State()); + final SubmitW4FormUseCase _submitW4FormUseCase; + String _formId = ''; void initialize(TaxForm? form) { if (form == null || form.formData.isEmpty) { @@ -92,7 +92,7 @@ class FormW4Cubit extends Cubit with BlocErrorHandler await handleError( emit: emit, action: () async { - final Map formData = { + final Map formData = { 'firstName': state.firstName, 'lastName': state.lastName, 'ssn': state.ssn, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_state.dart index 6c819d7d..f666ec78 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_state.dart @@ -3,6 +3,25 @@ import 'package:equatable/equatable.dart'; enum FormW4Status { initial, submitting, success, failure } class FormW4State extends Equatable { + + const FormW4State({ + this.currentStep = 0, + this.firstName = '', + this.lastName = '', + this.ssn = '', + this.address = '', + this.cityStateZip = '', + this.filingStatus = '', + this.multipleJobs = false, + this.qualifyingChildren = 0, + this.otherDependents = 0, + this.otherIncome = '', + this.deductions = '', + this.extraWithholding = '', + this.signature = '', + this.status = FormW4Status.initial, + this.errorMessage, + }); final int currentStep; // Personal Info @@ -29,25 +48,6 @@ class FormW4State extends Equatable { final FormW4Status status; final String? errorMessage; - const FormW4State({ - this.currentStep = 0, - this.firstName = '', - this.lastName = '', - this.ssn = '', - this.address = '', - this.cityStateZip = '', - this.filingStatus = '', - this.multipleJobs = false, - this.qualifyingChildren = 0, - this.otherDependents = 0, - this.otherIncome = '', - this.deductions = '', - this.extraWithholding = '', - this.signature = '', - this.status = FormW4Status.initial, - this.errorMessage, - }); - FormW4State copyWith({ int? currentStep, String? firstName, @@ -89,7 +89,7 @@ class FormW4State extends Equatable { int get totalCredits => (qualifyingChildren * 2000) + (otherDependents * 500); @override - List get props => [ + List get props => [ currentStep, firstName, lastName, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart index 3056926c..0d306644 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart @@ -9,8 +9,8 @@ import '../blocs/i9/form_i9_cubit.dart'; import '../blocs/i9/form_i9_state.dart'; class FormI9Page extends StatefulWidget { - final TaxForm? form; const FormI9Page({super.key, this.form}); + final TaxForm? form; @override State createState() => _FormI9PageState(); @@ -77,7 +77,7 @@ class _FormI9PageState extends State { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff_compliance.tax_forms.i9; + final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; final List> steps = >[ {'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_sub}, @@ -150,7 +150,7 @@ class _FormI9PageState extends State { Container( width: 64, height: 64, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: UiColors.tagSuccess, shape: BoxShape.circle, ), @@ -507,7 +507,7 @@ class _FormI9PageState extends State { ), const SizedBox(height: UiConstants.space1 + 2), DropdownButtonFormField( - value: state.state.isEmpty ? null : state.state, + initialValue: state.state.isEmpty ? null : state.state, onChanged: (String? val) => context.read().stateChanged(val ?? ''), items: _usStates.map((String stateAbbr) { @@ -828,7 +828,7 @@ class _FormI9PageState extends State { } String _getReadableCitizenship(String status) { - final i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields; + final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields; switch (status) { case 'CITIZEN': return i18n.status_us_citizen; @@ -848,7 +848,7 @@ class _FormI9PageState extends State { FormI9State state, List> steps, ) { - final i18n = Translations.of(context).staff_compliance.tax_forms.i9; + final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; return Container( padding: const EdgeInsets.all(UiConstants.space4), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart index 635e8c4a..1673a72a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -9,8 +10,8 @@ import '../blocs/w4/form_w4_cubit.dart'; import '../blocs/w4/form_w4_state.dart'; class FormW4Page extends StatefulWidget { - final TaxForm? form; const FormW4Page({super.key, this.form}); + final TaxForm? form; @override State createState() => _FormW4PageState(); @@ -123,7 +124,7 @@ class _FormW4PageState extends State { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff_compliance.tax_forms.w4; + final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; final List> steps = >[ {'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')}, @@ -198,7 +199,7 @@ class _FormW4PageState extends State { Container( width: 64, height: 64, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: UiColors.tagSuccess, shape: BoxShape.circle, ), @@ -1065,7 +1066,7 @@ class _FormW4PageState extends State { } String _getFilingStatusLabel(String status) { - final i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields; + final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields; switch (status) { case 'SINGLE': return i18n.status_single; @@ -1083,7 +1084,7 @@ class _FormW4PageState extends State { FormW4State state, List> steps, ) { - final i18n = Translations.of(context).staff_compliance.tax_forms.w4; + final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -1178,3 +1179,5 @@ class _FormW4PageState extends State { ); } } + + diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index b1d6c6ac..e8f3f52c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -150,12 +150,12 @@ class TaxFormsPage extends StatelessWidget { return GestureDetector( onTap: () async { if (form is I9TaxForm) { - final result = await Modular.to.pushNamed('i9', arguments: form); + final Object? result = await Modular.to.pushNamed('i9', arguments: form); if (result == true && context.mounted) { await BlocProvider.of(context).loadTaxForms(); } } else if (form is W4TaxForm) { - final result = await Modular.to.pushNamed('w4', arguments: form); + final Object? result = await Modular.to.pushNamed('w4', arguments: form); if (result == true && context.mounted) { await BlocProvider.of(context).loadTaxForms(); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/staff_tax_forms.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/staff_tax_forms.dart index 126a4e79..3d3eacc5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/staff_tax_forms.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/staff_tax_forms.dart @@ -1,3 +1,3 @@ -library staff_tax_forms; +library; export 'src/staff_tax_forms_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart index 4bce8605..c5795bb5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart @@ -4,12 +4,12 @@ import 'package:krow_domain/krow_domain.dart'; /// Arguments for adding a bank account. class AddBankAccountParams extends UseCaseArgument with EquatableMixin { - final StaffBankAccount account; const AddBankAccountParams({required this.account}); + final StaffBankAccount account; @override - List get props => [account]; + List get props => [account]; @override bool? get stringify => true; diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart index 48d4a863..2403b32d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart @@ -4,9 +4,9 @@ import '../arguments/add_bank_account_params.dart'; /// Use case to add a bank account. class AddBankAccountUseCase implements UseCase { - final BankAccountRepository _repository; AddBankAccountUseCase(this._repository); + final BankAccountRepository _repository; @override Future call(AddBankAccountParams params) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart index 2de67941..ec688bf3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart @@ -4,9 +4,9 @@ import '../repositories/bank_account_repository.dart'; /// Use case to fetch bank accounts. class GetBankAccountsUseCase implements NoInputUseCase> { - final BankAccountRepository _repository; GetBankAccountsUseCase(this._repository); + final BankAccountRepository _repository; @override Future> call() { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart index afa3c888..2fdf8b7e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart @@ -8,8 +8,6 @@ import 'bank_account_state.dart'; class BankAccountCubit extends Cubit with BlocErrorHandler { - final GetBankAccountsUseCase _getBankAccountsUseCase; - final AddBankAccountUseCase _addBankAccountUseCase; BankAccountCubit({ required GetBankAccountsUseCase getBankAccountsUseCase, @@ -17,6 +15,8 @@ class BankAccountCubit extends Cubit }) : _getBankAccountsUseCase = getBankAccountsUseCase, _addBankAccountUseCase = addBankAccountUseCase, super(const BankAccountState()); + final GetBankAccountsUseCase _getBankAccountsUseCase; + final AddBankAccountUseCase _addBankAccountUseCase; Future loadAccounts() async { emit(state.copyWith(status: BankAccountStatus.loading)); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart index 3073c78b..9a4c4661 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart @@ -4,18 +4,18 @@ import 'package:krow_domain/krow_domain.dart'; enum BankAccountStatus { initial, loading, loaded, error, accountAdded } class BankAccountState extends Equatable { + + const BankAccountState({ + this.status = BankAccountStatus.initial, + this.accounts = const [], + this.errorMessage, + this.showForm = false, + }); final BankAccountStatus status; final List accounts; final String? errorMessage; final bool showForm; - const BankAccountState({ - this.status = BankAccountStatus.initial, - this.accounts = const [], - this.errorMessage, - this.showForm = false, - }); - BankAccountState copyWith({ BankAccountStatus? status, List? accounts, @@ -31,5 +31,5 @@ class BankAccountState extends Equatable { } @override - List get props => [status, accounts, errorMessage, showForm]; + List get props => [status, accounts, errorMessage, showForm]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 698cfb6b..2da73a16 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -8,7 +8,6 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_state.dart'; -import 'package:krow_core/core.dart'; import '../widgets/add_account_form.dart'; class BankAccountPage extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart index 3ffac6ff..25fe4f76 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart @@ -1,15 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; import 'package:design_system/design_system.dart'; -import '../blocs/bank_account_cubit.dart'; class AddAccountForm extends StatefulWidget { + + const AddAccountForm({super.key, required this.strings, required this.onSubmit, required this.onCancel}); final dynamic strings; final Function(String bankName, String routing, String account, String type) onSubmit; final VoidCallback onCancel; - const AddAccountForm({super.key, required this.strings, required this.onSubmit, required this.onCancel}); - @override State createState() => _AddAccountFormState(); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/staff_bank_account.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/staff_bank_account.dart index 226d9758..17f7fc99 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/staff_bank_account.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/staff_bank_account.dart @@ -1,3 +1,3 @@ -library staff_bank_account; +library; export 'src/staff_bank_account_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart index eee89873..aa738d0c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart @@ -8,11 +8,11 @@ import '../../domain/repositories/time_card_repository.dart'; /// Implementation of [TimeCardRepository] using Firebase Data Connect. class TimeCardRepositoryImpl implements TimeCardRepository { - final dc.DataConnectService _service; /// Creates a [TimeCardRepositoryImpl]. TimeCardRepositoryImpl({dc.DataConnectService? service}) : _service = service ?? dc.DataConnectService.instance; + final dc.DataConnectService _service; @override Future> getTimeCards(DateTime month) async { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/arguments/get_time_cards_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/arguments/get_time_cards_arguments.dart index e0d76152..97740900 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/arguments/get_time_cards_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/arguments/get_time_cards_arguments.dart @@ -2,11 +2,11 @@ import 'package:krow_core/core.dart'; /// Arguments for the GetTimeCardsUseCase. class GetTimeCardsArguments extends UseCaseArgument { + + const GetTimeCardsArguments(this.month); /// The month to fetch time cards for. final DateTime month; - const GetTimeCardsArguments(this.month); - @override - List get props => [month]; + List get props => [month]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart index 1ee76890..c969c80e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart @@ -5,9 +5,9 @@ import '../repositories/time_card_repository.dart'; /// UseCase to retrieve time cards for a given month. class GetTimeCardsUseCase extends UseCase> { - final TimeCardRepository repository; GetTimeCardsUseCase(this.repository); + final TimeCardRepository repository; /// Executes the use case. /// diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart index 2b9a9217..a605a52c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart @@ -11,12 +11,12 @@ part 'time_card_state.dart'; /// BLoC to manage Time Card state. class TimeCardBloc extends Bloc with BlocErrorHandler { - final GetTimeCardsUseCase getTimeCards; TimeCardBloc({required this.getTimeCards}) : super(TimeCardInitial()) { on(_onLoadTimeCards); on(_onChangeMonth); } + final GetTimeCardsUseCase getTimeCards; /// Handles fetching time cards for the requested month. Future _onLoadTimeCards( @@ -25,7 +25,7 @@ class TimeCardBloc extends Bloc ) async { emit(TimeCardLoading()); await handleError( - emit: emit, + emit: emit.call, action: () async { final List cards = await getTimeCards( GetTimeCardsArguments(event.month), diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_event.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_event.dart index 1cf7317a..14f6a449 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_event.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_event.dart @@ -3,21 +3,21 @@ part of 'time_card_bloc.dart'; abstract class TimeCardEvent extends Equatable { const TimeCardEvent(); @override - List get props => []; + List get props => []; } class LoadTimeCards extends TimeCardEvent { - final DateTime month; const LoadTimeCards(this.month); + final DateTime month; @override - List get props => [month]; + List get props => [month]; } class ChangeMonth extends TimeCardEvent { - final DateTime month; const ChangeMonth(this.month); + final DateTime month; @override - List get props => [month]; + List get props => [month]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart index 4d75b832..fc89f303 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart @@ -3,16 +3,12 @@ part of 'time_card_bloc.dart'; abstract class TimeCardState extends Equatable { const TimeCardState(); @override - List get props => []; + List get props => []; } class TimeCardInitial extends TimeCardState {} class TimeCardLoading extends TimeCardState {} class TimeCardLoaded extends TimeCardState { - final List timeCards; - final DateTime selectedMonth; - final double totalHours; - final double totalEarnings; const TimeCardLoaded({ required this.timeCards, @@ -20,13 +16,17 @@ class TimeCardLoaded extends TimeCardState { required this.totalHours, required this.totalEarnings, }); + final List timeCards; + final DateTime selectedMonth; + final double totalHours; + final double totalEarnings; @override - List get props => [timeCards, selectedMonth, totalHours, totalEarnings]; + List get props => [timeCards, selectedMonth, totalHours, totalEarnings]; } class TimeCardError extends TimeCardState { - final String message; const TimeCardError(this.message); + final String message; @override - List get props => [message]; + List get props => [message]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index 243d9b35..ebce838b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -27,7 +28,7 @@ class _TimeCardPageState extends State { @override Widget build(BuildContext context) { - final t = Translations.of(context); + final Translations t = Translations.of(context); return BlocProvider.value( value: _bloc, child: Scaffold( @@ -49,7 +50,7 @@ class _TimeCardPageState extends State { ), ), body: BlocConsumer( - listener: (context, state) { + listener: (BuildContext context, TimeCardState state) { if (state is TimeCardError) { UiSnackbar.show( context, @@ -58,7 +59,7 @@ class _TimeCardPageState extends State { ); } }, - builder: (context, state) { + builder: (BuildContext context, TimeCardState state) { if (state is TimeCardLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is TimeCardError) { @@ -79,7 +80,7 @@ class _TimeCardPageState extends State { vertical: UiConstants.space6, ), child: Column( - children: [ + children: [ MonthSelector( selectedDate: state.selectedMonth, onPreviousMonth: () => _bloc.add(ChangeMonth( @@ -107,3 +108,4 @@ class _TimeCardPageState extends State { ); } } + diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/month_selector.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/month_selector.dart index f94a0485..b4069c30 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/month_selector.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/month_selector.dart @@ -4,9 +4,6 @@ import 'package:design_system/design_system.dart'; /// A widget that allows the user to navigate between months. class MonthSelector extends StatelessWidget { - final DateTime selectedDate; - final VoidCallback onPreviousMonth; - final VoidCallback onNextMonth; const MonthSelector({ super.key, @@ -14,6 +11,9 @@ class MonthSelector extends StatelessWidget { required this.onPreviousMonth, required this.onNextMonth, }); + final DateTime selectedDate; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; @override Widget build(BuildContext context) { @@ -26,7 +26,7 @@ class MonthSelector extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ IconButton( icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary), onPressed: onPreviousMonth, diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart index 0135e0cb..b3679f3f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart @@ -6,15 +6,15 @@ import 'timesheet_card.dart'; /// Displays the list of shift history or an empty state. class ShiftHistoryList extends StatelessWidget { - final List timesheets; const ShiftHistoryList({super.key, required this.timesheets}); + final List timesheets; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( t.staff_time_card.shift_history, style: UiTypography.title2b.copyWith( @@ -27,7 +27,7 @@ class ShiftHistoryList extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: UiConstants.space12), child: Column( - children: [ + children: [ const Icon(UiIcons.clock, size: 48, color: UiColors.iconSecondary), const SizedBox(height: UiConstants.space3), Text( @@ -39,7 +39,7 @@ class ShiftHistoryList extends StatelessWidget { ), ) else - ...timesheets.map((ts) => TimesheetCard(timesheet: ts)), + ...timesheets.map((TimeCard ts) => TimesheetCard(timesheet: ts)), ], ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_summary.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_summary.dart index 4b103490..1bdc4768 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_summary.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_summary.dart @@ -4,19 +4,19 @@ import 'package:core_localization/core_localization.dart'; /// Displays the total hours worked and total earnings for the selected month. class TimeCardSummary extends StatelessWidget { - final double totalHours; - final double totalEarnings; const TimeCardSummary({ super.key, required this.totalHours, required this.totalEarnings, }); + final double totalHours; + final double totalEarnings; @override Widget build(BuildContext context) { return Row( - children: [ + children: [ Expanded( child: _SummaryCard( icon: UiIcons.clock, @@ -38,15 +38,15 @@ class TimeCardSummary extends StatelessWidget { } class _SummaryCard extends StatelessWidget { - final IconData icon; - final String label; - final String value; const _SummaryCard({ required this.icon, required this.label, required this.value, }); + final IconData icon; + final String label; + final String value; @override Widget build(BuildContext context) { @@ -59,9 +59,9 @@ class _SummaryCard extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( - children: [ + children: [ Icon(icon, size: 16, color: UiColors.primary), const SizedBox(width: UiConstants.space2), Text( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart index 70f707e2..5e0ebc33 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart @@ -6,13 +6,13 @@ import 'package:krow_domain/krow_domain.dart'; /// A card widget displaying details of a single shift/timecard. class TimesheetCard extends StatelessWidget { - final TimeCard timesheet; const TimesheetCard({super.key, required this.timesheet}); + final TimeCard timesheet; @override Widget build(BuildContext context) { - final status = timesheet.status; + final TimeCardStatus status = timesheet.status; Color statusBg; Color statusColor; String statusText; @@ -40,7 +40,7 @@ class TimesheetCard extends StatelessWidget { break; } - final dateStr = DateFormat('EEE, MMM d').format(timesheet.date); + final String dateStr = DateFormat('EEE, MMM d').format(timesheet.date); return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -51,14 +51,14 @@ class TimesheetCard extends StatelessWidget { border: Border.all(color: UiColors.border), ), child: Column( - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( timesheet.shiftTitle, style: UiTypography.body1m.textPrimary, @@ -91,7 +91,7 @@ class TimesheetCard extends StatelessWidget { Wrap( spacing: UiConstants.space3, runSpacing: UiConstants.space1, - children: [ + children: [ _IconText(icon: UiIcons.calendar, text: dateStr), _IconText( icon: UiIcons.clock, @@ -109,7 +109,7 @@ class TimesheetCard extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Text( '${timesheet.totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${timesheet.hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}', style: UiTypography.body2r.textSecondary, @@ -130,9 +130,9 @@ class TimesheetCard extends StatelessWidget { String _formatTime(String t) { if (t.isEmpty) return '--:--'; try { - final parts = t.split(':'); + final List parts = t.split(':'); if (parts.length >= 2) { - final dt = DateTime(2000, 1, 1, int.parse(parts[0]), int.parse(parts[1])); + final DateTime dt = DateTime(2000, 1, 1, int.parse(parts[0]), int.parse(parts[1])); return DateFormat('h:mm a').format(dt); } return t; @@ -143,16 +143,16 @@ class TimesheetCard extends StatelessWidget { } class _IconText extends StatelessWidget { - final IconData icon; - final String text; const _IconText({required this.icon, required this.text}); + final IconData icon; + final String text; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, - children: [ + children: [ Icon(icon, size: 14, color: UiColors.iconSecondary), const SizedBox(width: UiConstants.space1), Text( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart index 59ff493b..9d8ce260 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart @@ -1,4 +1,4 @@ -library staff_time_card; +library; import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index 7937e0c1..f574b6d1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,17 +1,29 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'data/repositories_impl/attire_repository_impl.dart'; import 'domain/repositories/attire_repository.dart'; import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart'; -import 'presentation/blocs/attire_cubit.dart'; +import 'presentation/pages/attire_capture_page.dart'; import 'presentation/pages/attire_page.dart'; class StaffAttireModule extends Module { @override void binds(Injector i) { + /// third party services + i.addLazySingleton(ImagePicker.new); + + /// local services + i.addLazySingleton( + () => CameraService(i.get()), + ); + // Repository i.addLazySingleton(AttireRepositoryImpl.new); @@ -19,9 +31,10 @@ class StaffAttireModule extends Module { i.addLazySingleton(GetAttireOptionsUseCase.new); i.addLazySingleton(SaveAttireUseCase.new); i.addLazySingleton(UploadAttirePhotoUseCase.new); - + // BLoC - i.addLazySingleton(AttireCubit.new); + i.add(AttireCubit.new); + i.add(AttireCaptureCubit.new); } @override @@ -30,5 +43,12 @@ class StaffAttireModule extends Module { StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attire), child: (_) => const AttirePage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture), + child: (_) => AttireCapturePage( + item: r.args.data['item'] as AttireItem, + initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?, + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index cff32f53..65645ad8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -1,35 +1,27 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' + hide AttireVerificationStatus; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/attire_repository.dart'; /// Implementation of [AttireRepository]. /// -/// Delegates data access to [DataConnectService]. +/// Delegates data access to [StaffConnectorRepository]. class AttireRepositoryImpl implements AttireRepository { - /// The Data Connect service. - final DataConnectService _service; - /// Creates an [AttireRepositoryImpl]. - AttireRepositoryImpl({DataConnectService? service}) - : _service = service ?? DataConnectService.instance; + AttireRepositoryImpl({StaffConnectorRepository? connector}) + : _connector = + connector ?? DataConnectService.instance.getStaffRepository(); + + /// The Staff Connector repository. + final StaffConnectorRepository _connector; @override Future> getAttireOptions() async { - return _service.run(() async { - final QueryResult result = - await _service.connector.listAttireOptions().execute(); - return result.data.attireOptions - .map((ListAttireOptionsAttireOptions e) => AttireItem( - id: e.itemId, - label: e.label, - iconName: e.icon, - imageUrl: e.imageUrl, - isMandatory: e.isMandatory ?? false, - )) - .toList(); - }); + return _connector.getAttireOptions(); } @override @@ -37,16 +29,103 @@ class AttireRepositoryImpl implements AttireRepository { required List selectedItemIds, required Map photoUrls, }) async { - // TODO: Connect to actual backend mutation when available. - // For now, simulate network delay as per prototype behavior. - await Future.delayed(const Duration(seconds: 1)); + // We already upsert photos in uploadPhoto (to follow the new flow). + // This could save selections if there was a separate "SelectedAttire" table. + // For now, it's a no-op as the source of truth is the StaffAttire table. } @override - Future uploadPhoto(String itemId) async { - // TODO: Connect to actual storage service/mutation when available. - // For now, simulate upload delay and return mock URL. - await Future.delayed(const Duration(seconds: 1)); - return 'mock_url_for_$itemId'; + Future uploadPhoto(String itemId, String filePath) async { + // 1. Upload file to Core API + final FileUploadService uploadService = Modular.get(); + final FileUploadResponse uploadRes = await uploadService.uploadFile( + filePath: filePath, + fileName: filePath.split('/').last, + ); + + final String fileUri = uploadRes.fileUri; + + // 2. Create signed URL for the uploaded file + final SignedUrlService signedUrlService = Modular.get(); + final SignedUrlResponse signedUrlRes = await signedUrlService + .createSignedUrl(fileUri: fileUri); + final String photoUrl = signedUrlRes.signedUrl; + + // 3. Initiate verification job + final VerificationService verificationService = + Modular.get(); + final Staff staff = await _connector.getStaffProfile(); + + // Get item details for verification rules + final List options = await _connector.getAttireOptions(); + final AttireItem targetItem = options.firstWhere( + (AttireItem e) => e.id == itemId, + ); + final String dressCode = + '${targetItem.description ?? ''} ${targetItem.label}'.trim(); + + final VerificationResponse verifyRes = await verificationService + .createVerification( + type: 'attire', + subjectType: 'worker', + subjectId: staff.id, + fileUri: fileUri, + rules: {'dressCode': dressCode}, + ); + final String verificationId = verifyRes.verificationId; + VerificationStatus currentStatus = verifyRes.status; + + // 4. Poll for status until it's finished or timeout (max 10 seconds) + try { + int attempts = 0; + bool isFinished = false; + while (!isFinished && attempts < 5) { + await Future.delayed(const Duration(seconds: 2)); + final VerificationResponse statusRes = await verificationService + .getStatus(verificationId); + currentStatus = statusRes.status; + if (currentStatus != VerificationStatus.pending && + currentStatus != VerificationStatus.processing) { + isFinished = true; + } + attempts++; + } + } catch (e) { + debugPrint('Polling failed or timed out: $e'); + // Continue anyway, as we have the verificationId + } + + // 5. Update Data Connect + await _connector.upsertStaffAttire( + attireOptionId: itemId, + photoUrl: photoUrl, + verificationId: verificationId, + verificationStatus: _mapToAttireStatus(currentStatus), + ); + + // 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status + final List finalOptions = await _connector.getAttireOptions(); + return finalOptions.firstWhere((AttireItem e) => e.id == itemId); + } + + AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) { + switch (status) { + case VerificationStatus.pending: + return AttireVerificationStatus.pending; + case VerificationStatus.processing: + return AttireVerificationStatus.processing; + case VerificationStatus.autoPass: + return AttireVerificationStatus.autoPass; + case VerificationStatus.autoFail: + return AttireVerificationStatus.autoFail; + case VerificationStatus.needsReview: + return AttireVerificationStatus.needsReview; + case VerificationStatus.approved: + return AttireVerificationStatus.approved; + case VerificationStatus.rejected: + return AttireVerificationStatus.rejected; + case VerificationStatus.error: + return AttireVerificationStatus.error; + } } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart index 5894e163..e26a7c6d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart @@ -2,17 +2,17 @@ import 'package:krow_core/core.dart'; /// Arguments for saving staff attire selections. class SaveAttireArguments extends UseCaseArgument { - /// List of selected attire item IDs. - final List selectedItemIds; - - /// Map of item IDs to uploaded photo URLs. - final Map photoUrls; /// Creates a [SaveAttireArguments]. const SaveAttireArguments({ required this.selectedItemIds, required this.photoUrls, }); + /// List of selected attire item IDs. + final List selectedItemIds; + + /// Map of item IDs to uploaded photo URLs. + final Map photoUrls; @override List get props => [selectedItemIds, photoUrls]; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart index 14ea832d..dafdac1f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart @@ -2,15 +2,22 @@ import 'package:krow_core/core.dart'; /// Arguments for uploading an attire photo. class UploadAttirePhotoArguments extends UseCaseArgument { - /// The ID of the attire item being uploaded. - final String itemId; // Note: typically we'd pass a File or path here too, but the prototype likely picks it internally or mocking it. // The current logic takes "itemId" and returns a mock URL. // We'll stick to that signature for now to "preserve behavior". /// Creates a [UploadAttirePhotoArguments]. - const UploadAttirePhotoArguments({required this.itemId}); + const UploadAttirePhotoArguments({ + required this.itemId, + required this.filePath, + }); + + /// The ID of the attire item being uploaded. + final String itemId; + + /// The local path to the photo file. + final String filePath; @override - List get props => [itemId]; + List get props => [itemId, filePath]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart index 1b4742ad..a57107c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -4,8 +4,8 @@ abstract interface class AttireRepository { /// Fetches the list of available attire options. Future> getAttireOptions(); - /// Simulates uploading a photo for a specific attire item. - Future uploadPhoto(String itemId); + /// Uploads a photo for a specific attire item. + Future uploadPhoto(String itemId, String filePath); /// Saves the user's attire selection and attestations. Future saveAttire({ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart index 9d8490d3..42094095 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart @@ -5,10 +5,10 @@ import '../repositories/attire_repository.dart'; /// Use case to fetch available attire options. class GetAttireOptionsUseCase extends NoInputUseCase> { - final AttireRepository _repository; /// Creates a [GetAttireOptionsUseCase]. GetAttireOptionsUseCase(this._repository); + final AttireRepository _repository; @override Future> call() { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart index e8adb221..837774b4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart @@ -5,10 +5,10 @@ import '../repositories/attire_repository.dart'; /// Use case to save user's attire selections. class SaveAttireUseCase extends UseCase { - final AttireRepository _repository; /// Creates a [SaveAttireUseCase]. SaveAttireUseCase(this._repository); + final AttireRepository _repository; @override Future call(SaveAttireArguments arguments) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart index 2b5f6698..39cd456b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -1,16 +1,17 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../arguments/upload_attire_photo_arguments.dart'; import '../repositories/attire_repository.dart'; /// Use case to upload a photo for an attire item. -class UploadAttirePhotoUseCase extends UseCase { - final AttireRepository _repository; - +class UploadAttirePhotoUseCase + extends UseCase { /// Creates a [UploadAttirePhotoUseCase]. UploadAttirePhotoUseCase(this._repository); + final AttireRepository _repository; @override - Future call(UploadAttirePhotoArguments arguments) { - return _repository.uploadPhoto(arguments.itemId); + Future call(UploadAttirePhotoArguments arguments) { + return _repository.uploadPhoto(arguments.itemId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart new file mode 100644 index 00000000..bc643b5a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -0,0 +1,107 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/domain/arguments/save_attire_arguments.dart'; +import 'package:staff_attire/src/domain/usecases/get_attire_options_usecase.dart'; +import 'package:staff_attire/src/domain/usecases/save_attire_usecase.dart'; + +import 'attire_state.dart'; + +class AttireCubit extends Cubit + with BlocErrorHandler { + AttireCubit(this._getAttireOptionsUseCase, this._saveAttireUseCase) + : super(const AttireState()) { + loadOptions(); + } + final GetAttireOptionsUseCase _getAttireOptionsUseCase; + final SaveAttireUseCase _saveAttireUseCase; + + Future loadOptions() async { + emit(state.copyWith(status: AttireStatus.loading)); + await handleError( + emit: emit, + action: () async { + final List options = await _getAttireOptionsUseCase(); + + // Extract photo URLs and selection status from backend data + final Map photoUrls = {}; + final List selectedIds = []; + + for (final AttireItem item in options) { + if (item.photoUrl != null) { + photoUrls[item.id] = item.photoUrl!; + } + // If mandatory or has photo, consider it selected initially + if (item.isMandatory || item.photoUrl != null) { + selectedIds.add(item.id); + } + } + + emit( + state.copyWith( + status: AttireStatus.success, + options: options, + selectedIds: selectedIds, + photoUrls: photoUrls, + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: AttireStatus.failure, errorMessage: errorKey), + ); + } + + void toggleSelection(String id) { + // Prevent unselecting mandatory items + if (state.isMandatory(id)) return; + + final List currentSelection = List.from(state.selectedIds); + if (currentSelection.contains(id)) { + currentSelection.remove(id); + } else { + currentSelection.add(id); + } + emit(state.copyWith(selectedIds: currentSelection)); + } + + void updateFilter(String filter) { + emit(state.copyWith(filter: filter)); + } + + void syncCapturedPhoto(AttireItem item) { + // Update the options list with the new item data + final List updatedOptions = state.options + .map((AttireItem e) => e.id == item.id ? item : e) + .toList(); + + // Update the photo URLs map + final Map updatedPhotos = Map.from( + state.photoUrls, + ); + if (item.photoUrl != null) { + updatedPhotos[item.id] = item.photoUrl!; + } + + emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos)); + } + + Future save() async { + if (!state.canSave) return; + + emit(state.copyWith(status: AttireStatus.saving)); + await handleError( + emit: emit, + action: () async { + await _saveAttireUseCase( + SaveAttireArguments( + selectedItemIds: state.selectedIds, + photoUrls: state.photoUrls, + ), + ); + emit(state.copyWith(status: AttireStatus.saved)); + }, + onError: (String errorKey) => + state.copyWith(status: AttireStatus.failure, errorMessage: errorKey), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart similarity index 59% rename from apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart rename to apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index aba87810..e137aff2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -4,51 +4,62 @@ import 'package:krow_domain/krow_domain.dart'; enum AttireStatus { initial, loading, success, failure, saving, saved } class AttireState extends Equatable { - final AttireStatus status; - final List options; - final List selectedIds; - final Map photoUrls; - final Map uploadingStatus; - final bool attestationChecked; - final String? errorMessage; - const AttireState({ this.status = AttireStatus.initial, this.options = const [], this.selectedIds = const [], this.photoUrls = const {}, - this.uploadingStatus = const {}, - this.attestationChecked = false, + this.filter = 'All', this.errorMessage, }); - - bool get uploading => uploadingStatus.values.any((bool u) => u); + final AttireStatus status; + final List options; + final List selectedIds; + final Map photoUrls; + final String filter; + final String? errorMessage; /// Helper to check if item is mandatory bool isMandatory(String id) { - return options.firstWhere((AttireItem e) => e.id == id, orElse: () => const AttireItem(id: '', label: '')).isMandatory; + return options + .firstWhere( + (AttireItem e) => e.id == id, + orElse: () => const AttireItem(id: '', code: '', label: ''), + ) + .isMandatory; } /// Validation logic bool get allMandatorySelected { - final Iterable mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); + final Iterable mandatoryIds = options + .where((AttireItem e) => e.isMandatory) + .map((AttireItem e) => e.id); return mandatoryIds.every((String id) => selectedIds.contains(id)); } bool get allMandatoryHavePhotos { - final Iterable mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); + final Iterable mandatoryIds = options + .where((AttireItem e) => e.isMandatory) + .map((AttireItem e) => e.id); return mandatoryIds.every((String id) => photoUrls.containsKey(id)); } - bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading; + bool get canSave => allMandatorySelected && allMandatoryHavePhotos; + + List get filteredOptions { + return options.where((AttireItem item) { + if (filter == 'Required') return item.isMandatory; + if (filter == 'Non-Essential') return !item.isMandatory; + return true; + }).toList(); + } AttireState copyWith({ AttireStatus? status, List? options, List? selectedIds, Map? photoUrls, - Map? uploadingStatus, - bool? attestationChecked, + String? filter, String? errorMessage, }) { return AttireState( @@ -56,20 +67,18 @@ class AttireState extends Equatable { options: options ?? this.options, selectedIds: selectedIds ?? this.selectedIds, photoUrls: photoUrls ?? this.photoUrls, - uploadingStatus: uploadingStatus ?? this.uploadingStatus, - attestationChecked: attestationChecked ?? this.attestationChecked, + filter: filter ?? this.filter, errorMessage: errorMessage, ); } @override List get props => [ - status, - options, - selectedIds, - photoUrls, - uploadingStatus, - attestationChecked, - errorMessage - ]; + status, + options, + selectedIds, + photoUrls, + filter, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart new file mode 100644 index 00000000..a3b9eca1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -0,0 +1,44 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; +import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; + +import 'attire_capture_state.dart'; + +class AttireCaptureCubit extends Cubit + with BlocErrorHandler { + AttireCaptureCubit(this._uploadAttirePhotoUseCase) + : super(const AttireCaptureState()); + + final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase; + + void toggleAttestation(bool value) { + emit(state.copyWith(isAttested: value)); + } + + Future uploadPhoto(String itemId, String filePath) async { + emit(state.copyWith(status: AttireCaptureStatus.uploading)); + + await handleError( + emit: emit, + action: () async { + final AttireItem item = await _uploadAttirePhotoUseCase( + UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), + ); + + emit( + state.copyWith( + status: AttireCaptureStatus.success, + photoUrl: item.photoUrl, + updatedItem: item, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: AttireCaptureStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart new file mode 100644 index 00000000..79f6e28a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum AttireCaptureStatus { initial, uploading, success, failure } + +class AttireCaptureState extends Equatable { + const AttireCaptureState({ + this.status = AttireCaptureStatus.initial, + this.isAttested = false, + this.photoUrl, + this.updatedItem, + this.errorMessage, + }); + + final AttireCaptureStatus status; + final bool isAttested; + final String? photoUrl; + final AttireItem? updatedItem; + final String? errorMessage; + + AttireCaptureState copyWith({ + AttireCaptureStatus? status, + bool? isAttested, + String? photoUrl, + AttireItem? updatedItem, + String? errorMessage, + }) { + return AttireCaptureState( + status: status ?? this.status, + isAttested: isAttested ?? this.isAttested, + photoUrl: photoUrl ?? this.photoUrl, + updatedItem: updatedItem ?? this.updatedItem, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + status, + isAttested, + photoUrl, + updatedItem, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart deleted file mode 100644 index feae446a..00000000 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../../domain/arguments/save_attire_arguments.dart'; -import '../../domain/arguments/upload_attire_photo_arguments.dart'; -import '../../domain/usecases/get_attire_options_usecase.dart'; -import '../../domain/usecases/save_attire_usecase.dart'; -import '../../domain/usecases/upload_attire_photo_usecase.dart'; -import 'attire_state.dart'; - -class AttireCubit extends Cubit - with BlocErrorHandler { - final GetAttireOptionsUseCase _getAttireOptionsUseCase; - final SaveAttireUseCase _saveAttireUseCase; - final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase; - - AttireCubit( - this._getAttireOptionsUseCase, - this._saveAttireUseCase, - this._uploadAttirePhotoUseCase, - ) : super(const AttireState()) { - loadOptions(); - } - - Future loadOptions() async { - emit(state.copyWith(status: AttireStatus.loading)); - await handleError( - emit: emit, - action: () async { - final List options = await _getAttireOptionsUseCase(); - - // Auto-select mandatory items initially as per prototype - final List mandatoryIds = - options - .where((AttireItem e) => e.isMandatory) - .map((AttireItem e) => e.id) - .toList(); - - final List initialSelection = List.from( - state.selectedIds, - ); - for (final String id in mandatoryIds) { - if (!initialSelection.contains(id)) { - initialSelection.add(id); - } - } - - emit( - state.copyWith( - status: AttireStatus.success, - options: options, - selectedIds: initialSelection, - ), - ); - }, - onError: - (String errorKey) => state.copyWith( - status: AttireStatus.failure, - errorMessage: errorKey, - ), - ); - } - - void toggleSelection(String id) { - // Prevent unselecting mandatory items - if (state.isMandatory(id)) return; - - final List currentSelection = List.from(state.selectedIds); - if (currentSelection.contains(id)) { - currentSelection.remove(id); - } else { - currentSelection.add(id); - } - emit(state.copyWith(selectedIds: currentSelection)); - } - - void toggleAttestation(bool value) { - emit(state.copyWith(attestationChecked: value)); - } - - Future uploadPhoto(String itemId) async { - final Map currentUploading = Map.from( - state.uploadingStatus, - ); - currentUploading[itemId] = true; - emit(state.copyWith(uploadingStatus: currentUploading)); - - await handleError( - emit: emit, - action: () async { - final String url = await _uploadAttirePhotoUseCase( - UploadAttirePhotoArguments(itemId: itemId), - ); - - final Map currentPhotos = Map.from( - state.photoUrls, - ); - currentPhotos[itemId] = url; - - // Auto-select item on upload success if not selected - final List currentSelection = List.from( - state.selectedIds, - ); - if (!currentSelection.contains(itemId)) { - currentSelection.add(itemId); - } - - final Map updatedUploading = Map.from( - state.uploadingStatus, - ); - updatedUploading[itemId] = false; - - emit( - state.copyWith( - uploadingStatus: updatedUploading, - photoUrls: currentPhotos, - selectedIds: currentSelection, - ), - ); - }, - onError: (String errorKey) { - final Map updatedUploading = Map.from( - state.uploadingStatus, - ); - updatedUploading[itemId] = false; - // Could handle error specifically via snackbar event - // For now, attaching the error message but keeping state generally usable - return state.copyWith( - uploadingStatus: updatedUploading, - errorMessage: errorKey, - ); - }, - ); - } - - Future save() async { - if (!state.canSave) return; - - emit(state.copyWith(status: AttireStatus.saving)); - await handleError( - emit: emit, - action: () async { - await _saveAttireUseCase( - SaveAttireArguments( - selectedItemIds: state.selectedIds, - photoUrls: state.photoUrls, - ), - ); - emit(state.copyWith(status: AttireStatus.saved)); - }, - onError: - (String errorKey) => state.copyWith( - status: AttireStatus.failure, - errorMessage: errorKey, - ), - ); - } -} - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart new file mode 100644 index 00000000..82109743 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -0,0 +1,270 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; + +import '../widgets/attire_capture_page/footer_section.dart'; +import '../widgets/attire_capture_page/image_preview_section.dart'; +import '../widgets/attire_capture_page/info_section.dart'; + +/// The [AttireCapturePage] allows users to capture or upload a photo of a specific attire item. +class AttireCapturePage extends StatefulWidget { + /// Creates an [AttireCapturePage]. + const AttireCapturePage({ + super.key, + required this.item, + this.initialPhotoUrl, + }); + + /// The attire item being captured. + final AttireItem item; + + /// Optional initial photo URL if it was already uploaded. + final String? initialPhotoUrl; + + @override + State createState() => _AttireCapturePageState(); +} + +class _AttireCapturePageState extends State { + String? _selectedLocalPath; + + /// Whether a verification status is already present for this item. + bool get _hasVerificationStatus => widget.item.verificationStatus != null; + + /// Whether the item is currently pending verification. + bool get _isPending => + widget.item.verificationStatus == AttireVerificationStatus.pending; + + /// On gallery button press + Future _onGallery(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); + return; + } + + try { + final GalleryService service = Modular.get(); + final String? path = await service.pickImage(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access gallery: $e'); + } + } + } + + /// On camera button press + Future _onCamera(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); + return; + } + + try { + final CameraService service = Modular.get(); + final String? path = await service.takePhoto(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access camera: $e'); + } + } + } + + /// Show a bottom sheet for reuploading options. + void _onReupload(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (BuildContext sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Gallery'), + onTap: () { + Modular.to.pop(); + _onGallery(context); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Camera'), + onTap: () { + Modular.to.pop(); + _onCamera(context); + }, + ), + ], + ), + ), + ); + } + + void _showAttestationWarning(BuildContext context) { + UiSnackbar.show( + context, + message: 'Please attest that you own this item.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + void _showError(BuildContext context, String message) { + debugPrint(message); + UiSnackbar.show( + context, + message: 'Could not access camera or gallery. Please try again.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + Future _onSubmit(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + if (_selectedLocalPath == null) return; + + await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!); + if (context.mounted && cubit.state.status == AttireCaptureStatus.success) { + setState(() { + _selectedLocalPath = null; + }); + } + } + + String _getStatusText(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', + AttireVerificationStatus.pending => 'Pending Verification', + _ => hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', + }; + } + + Color _getStatusColor(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => UiColors.textSuccess, + AttireVerificationStatus.rejected => UiColors.textError, + AttireVerificationStatus.pending => UiColors.textWarning, + _ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive, + }; + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Modular.get(), + child: Builder( + builder: (BuildContext context) { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + return Scaffold( + appBar: UiAppBar( + title: widget.item.label, + onLeadingPressed: () { + Modular.to.toAttire(); + }, + ), + body: BlocConsumer( + bloc: cubit, + listener: (BuildContext context, AttireCaptureState state) { + if (state.status == AttireCaptureStatus.failure) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? 'Error'), + type: UiSnackbarType.error, + ); + } + + if (state.status == AttireCaptureStatus.success) { + UiSnackbar.show( + context, + message: 'Attire image submitted for verification', + type: UiSnackbarType.success, + ); + Modular.to.toAttire(); + } + }, + builder: (BuildContext context, AttireCaptureState state) { + final String? currentPhotoUrl = + state.photoUrl ?? widget.initialPhotoUrl; + final bool hasUploadedPhoto = currentPhotoUrl != null; + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: [ + ImagePreviewSection( + selectedLocalPath: _selectedLocalPath, + currentPhotoUrl: currentPhotoUrl, + referenceImageUrl: widget.item.imageUrl, + ), + const SizedBox(height: UiConstants.space1), + InfoSection( + description: widget.item.description, + statusText: _getStatusText(hasUploadedPhoto), + statusColor: _getStatusColor(hasUploadedPhoto), + isPending: _isPending, + showCheckbox: !_hasVerificationStatus, + isAttested: state.isAttested, + onAttestationChanged: (bool? val) { + cubit.toggleAttestation(val ?? false); + }, + ), + ], + ), + ), + ), + FooterSection( + isUploading: + state.status == AttireCaptureStatus.uploading, + selectedLocalPath: _selectedLocalPath, + hasVerificationStatus: _hasVerificationStatus, + hasUploadedPhoto: hasUploadedPhoto, + updatedItem: state.updatedItem, + onGallery: () => _onGallery(context), + onCamera: () => _onCamera(context), + onSubmit: () => _onSubmit(context), + onReupload: () => _onReupload(context), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index c788cfe0..280fd344 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -1,101 +1,114 @@ +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:core_localization/core_localization.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; -import '../blocs/attire_cubit.dart'; -import '../blocs/attire_state.dart'; -import '../widgets/attestation_checkbox.dart'; -import '../widgets/attire_bottom_bar.dart'; -import '../widgets/attire_grid.dart'; +import '../widgets/attire_filter_chips.dart'; import '../widgets/attire_info_card.dart'; +import '../widgets/attire_item_card.dart'; class AttirePage extends StatelessWidget { const AttirePage({super.key}); @override Widget build(BuildContext context) { - // Note: t.staff_profile_attire is available via re-export of core_localization final AttireCubit cubit = Modular.get(); - return BlocProvider.value( - value: cubit, - child: Scaffold( - backgroundColor: UiColors.background, // FAFBFC - appBar: AppBar( - backgroundColor: UiColors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary), - onPressed: () => Modular.to.pop(), - ), - title: Text( - t.staff_profile_attire.title, - style: UiTypography.headline3m.textPrimary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), - ), - body: BlocConsumer( + return Scaffold( + appBar: UiAppBar( + title: t.staff_profile_attire.title, + showBackButton: true, + onLeadingPressed: () => Modular.to.toProfile(), + ), + body: BlocProvider.value( + value: cubit, + child: BlocConsumer( listener: (BuildContext context, AttireState state) { if (state.status == AttireStatus.failure) { UiSnackbar.show( context, message: translateErrorKey(state.errorMessage ?? 'Error'), type: UiSnackbarType.error, - margin: const EdgeInsets.only( - bottom: 150, - left: UiConstants.space4, - right: UiConstants.space4, - ), ); } - if (state.status == AttireStatus.saved) { - Modular.to.pop(); - } }, builder: (BuildContext context, AttireState state) { if (state.status == AttireStatus.loading && state.options.isEmpty) { return const Center(child: CircularProgressIndicator()); } + final List filteredOptions = state.filteredOptions; + return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UiConstants.space5), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const AttireInfoCard(), const SizedBox(height: UiConstants.space6), - AttireGrid( - items: state.options, - selectedIds: state.selectedIds, - photoUrls: state.photoUrls, - uploadingStatus: state.uploadingStatus, - onToggle: cubit.toggleSelection, - onUpload: cubit.uploadPhoto, + + // Filter Chips + AttireFilterChips( + selectedFilter: state.filter, + onFilterChanged: cubit.updateFilter, ), const SizedBox(height: UiConstants.space6), - AttestationCheckbox( - isChecked: state.attestationChecked, - onChanged: (bool? val) => cubit.toggleAttestation(val ?? false), - ), + + // Item List + if (filteredOptions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space10, + ), + child: Center( + child: Column( + children: [ + const Icon( + UiIcons.shirt, + size: 48, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No items found for this filter.', + style: UiTypography.body1m.textSecondary, + ), + ], + ), + ), + ) + else + ...filteredOptions.map((AttireItem item) { + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: AttireItemCard( + item: item, + isUploading: false, + uploadedPhotoUrl: state.photoUrls[item.id], + onTap: () { + Modular.to.toAttireCapture( + item: item, + initialPhotoUrl: state.photoUrls[item.id], + ); + }, + ), + ); + }), const SizedBox(height: UiConstants.space20), ], ), ), ), - AttireBottomBar( - canSave: state.canSave, - allMandatorySelected: state.allMandatorySelected, - allMandatoryHavePhotos: state.allMandatoryHavePhotos, - attestationChecked: state.attestationChecked, - onSave: cubit.save, - ), ], ); }, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart index b7a1b7c8..1594b993 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart @@ -3,14 +3,14 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; class AttestationCheckbox extends StatelessWidget { - final bool isChecked; - final ValueChanged onChanged; const AttestationCheckbox({ super.key, required this.isChecked, required this.onChanged, }); + final bool isChecked; + final ValueChanged onChanged; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart index 54b2fa4f..7192f818 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart @@ -3,11 +3,6 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; class AttireBottomBar extends StatelessWidget { - final bool canSave; - final bool allMandatorySelected; - final bool allMandatoryHavePhotos; - final bool attestationChecked; - final VoidCallback onSave; const AttireBottomBar({ super.key, @@ -17,6 +12,11 @@ class AttireBottomBar extends StatelessWidget { required this.attestationChecked, required this.onSave, }); + final bool canSave; + final bool allMandatorySelected; + final bool allMandatoryHavePhotos; + final bool attestationChecked; + final VoidCallback onSave; @override Widget build(BuildContext context) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart new file mode 100644 index 00000000..0e670951 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireImagePreview extends StatelessWidget { + const AttireImagePreview({super.key, this.imageUrl, this.localPath}); + + final String? imageUrl; + final String? localPath; + + ImageProvider get _imageProvider { + if (localPath != null) { + return FileImage(File(localPath!)); + } + return NetworkImage( + imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ); + } + + void _viewEnlargedImage(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxHeight: 500, maxWidth: 500), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + image: DecorationImage( + image: _imageProvider, + fit: BoxFit.contain, + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => _viewEnlargedImage(context), + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow( + color: Color(0x19000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + image: DecorationImage(image: _imageProvider, fit: BoxFit.cover), + ), + child: const Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.all(8.0), + child: Icon( + UiIcons.search, + color: UiColors.white, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart new file mode 100644 index 00000000..e6bcb712 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireUploadButtons extends StatelessWidget { + const AttireUploadButtons({ + super.key, + required this.onGallery, + required this.onCamera, + }); + + final VoidCallback onGallery; + final VoidCallback onCamera; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: UiButton.secondary( + leadingIcon: UiIcons.gallery, + text: 'Gallery', + onPressed: onGallery, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: UiButton.primary( + leadingIcon: UiIcons.camera, + text: 'Camera', + onPressed: onCamera, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart new file mode 100644 index 00000000..2799aea2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart @@ -0,0 +1,46 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireVerificationStatusCard extends StatelessWidget { + const AttireVerificationStatusCard({ + super.key, + required this.statusText, + required this.statusColor, + }); + + final String statusText; + final Color statusColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + const Icon(UiIcons.info, color: UiColors.primary, size: 24), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Verification Status', + style: UiTypography.footnote2m.textPrimary, + ), + Text( + statusText, + style: UiTypography.body2m.copyWith(color: statusColor), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart new file mode 100644 index 00000000..6f0b4c2e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -0,0 +1,109 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'attire_upload_buttons.dart'; + +/// Handles the primary actions at the bottom of the page. +class FooterSection extends StatelessWidget { + /// Creates a [FooterSection]. + const FooterSection({ + super.key, + required this.isUploading, + this.selectedLocalPath, + required this.hasVerificationStatus, + required this.hasUploadedPhoto, + this.updatedItem, + required this.onGallery, + required this.onCamera, + required this.onSubmit, + required this.onReupload, + }); + + /// Whether a photo is currently being uploaded. + final bool isUploading; + + /// The local path of the selected photo. + final String? selectedLocalPath; + + /// Whether the item already has a verification status. + final bool hasVerificationStatus; + + /// Whether the item has an uploaded photo. + final bool hasUploadedPhoto; + + /// The updated attire item, if any. + final AttireItem? updatedItem; + + /// Callback to open the gallery. + final VoidCallback onGallery; + + /// Callback to open the camera. + final VoidCallback onCamera; + + /// Callback to submit the photo. + final VoidCallback onSubmit; + + /// Callback to trigger the re-upload flow. + final VoidCallback onReupload; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isUploading) + const Center( + child: Padding( + padding: EdgeInsets.all(UiConstants.space4), + child: CircularProgressIndicator(), + ), + ) + else + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + if (selectedLocalPath != null) { + return UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: onSubmit, + ); + } + + if (hasVerificationStatus) { + return UiButton.secondary( + fullWidth: true, + text: 'Re Upload', + onPressed: onReupload, + ); + } + + return Column( + children: [ + AttireUploadButtons(onGallery: onGallery, onCamera: onCamera), + if (hasUploadedPhoto) ...[ + const SizedBox(height: UiConstants.space4), + UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: () { + if (updatedItem != null) { + Modular.to.pop(updatedItem); + } + }, + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart new file mode 100644 index 00000000..18a6e930 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart @@ -0,0 +1,96 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_image_preview.dart'; + +/// Displays the comparison between the reference example and the user's photo. +class ImagePreviewSection extends StatelessWidget { + /// Creates an [ImagePreviewSection]. + const ImagePreviewSection({ + super.key, + this.selectedLocalPath, + this.currentPhotoUrl, + this.referenceImageUrl, + }); + + /// The local file path of the selected image. + final String? selectedLocalPath; + + /// The URL of the currently uploaded photo. + final String? currentPhotoUrl; + + /// The URL of the reference example image. + final String? referenceImageUrl; + + @override + Widget build(BuildContext context) { + if (selectedLocalPath != null) { + return Column( + children: [ + Text( + 'Review the attire item', + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(localPath: selectedLocalPath), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + if (currentPhotoUrl != null) { + return Column( + children: [ + Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(imageUrl: currentPhotoUrl), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + return Column( + children: [ + AttireImagePreview(imageUrl: referenceImageUrl), + const SizedBox(height: UiConstants.space4), + Text( + 'Example of the item that you need to upload.', + style: UiTypography.body1b.textSecondary, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +/// Displays the reference item photo as an example. +class ReferenceExample extends StatelessWidget { + /// Creates a [ReferenceExample]. + const ReferenceExample({super.key, this.imageUrl}); + + /// The URL of the image to display. + final String? imageUrl; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Reference Example', style: UiTypography.body2b.textSecondary), + const SizedBox(height: UiConstants.space1), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Image.network( + imageUrl ?? '', + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart new file mode 100644 index 00000000..be5995f2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart @@ -0,0 +1,89 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../attestation_checkbox.dart'; +import 'attire_verification_status_card.dart'; + +/// Displays the item details, verification status, and attestation checkbox. +class InfoSection extends StatelessWidget { + /// Creates an [InfoSection]. + const InfoSection({ + super.key, + this.description, + required this.statusText, + required this.statusColor, + required this.isPending, + required this.showCheckbox, + required this.isAttested, + required this.onAttestationChanged, + }); + + /// The description of the attire item. + final String? description; + + /// The text to display for the verification status. + final String statusText; + + /// The color to use for the verification status text. + final Color statusColor; + + /// Whether the item is currently pending verification. + final bool isPending; + + /// Whether to show the attestation checkbox. + final bool showCheckbox; + + /// Whether the user has attested to owning the item. + final bool isAttested; + + /// Callback when the attestation status changes. + final ValueChanged onAttestationChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (description != null) + Text( + description!, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space8), + + // Pending Banner + if (isPending) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.tagPending, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Text( + 'A Manager will Verify This Item', + style: UiTypography.body2b.textWarning, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: UiConstants.space4), + ], + + // Verification info + AttireVerificationStatusCard( + statusText: statusText, + statusColor: statusColor, + ), + const SizedBox(height: UiConstants.space6), + + if (showCheckbox) ...[ + AttestationCheckbox( + isChecked: isAttested, + onChanged: onAttestationChanged, + ), + const SizedBox(height: UiConstants.space6), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart new file mode 100644 index 00000000..b7ca10eb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireFilterChips extends StatelessWidget { + const AttireFilterChips({ + super.key, + required this.selectedFilter, + required this.onFilterChanged, + }); + + final String selectedFilter; + final ValueChanged onFilterChanged; + + Widget _buildFilterChip(String label) { + final bool isSelected = selectedFilter == label; + return GestureDetector( + onTap: () => onFilterChanged(label), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: UiConstants.radiusFull, + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip('All'), + const SizedBox(width: UiConstants.space2), + _buildFilterChip('Required'), + const SizedBox(width: UiConstants.space2), + _buildFilterChip('Non-Essential'), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart index ac003651..dc4a0c9e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart @@ -5,13 +5,6 @@ import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; class AttireGrid extends StatelessWidget { - final List items; - final List selectedIds; - final Map photoUrls; - final Map uploadingStatus; - final Function(String id) onToggle; - final Function(String id) onUpload; - const AttireGrid({ super.key, required this.items, @@ -21,6 +14,12 @@ class AttireGrid extends StatelessWidget { required this.onToggle, required this.onUpload, }); + final List items; + final List selectedIds; + final Map photoUrls; + final Map uploadingStatus; + final Function(String id) onToggle; + final Function(String id) onUpload; @override Widget build(BuildContext context) { @@ -53,7 +52,9 @@ class AttireGrid extends StatelessWidget { ) { return Container( decoration: BoxDecoration( - color: isSelected ? UiColors.primary.withOpacity(0.1) : Colors.transparent, + color: isSelected + ? UiColors.primary.withOpacity(0.1) + : Colors.transparent, borderRadius: UiConstants.radiusSm, border: Border.all( color: isSelected ? UiColors.primary : UiColors.border, @@ -67,19 +68,17 @@ class AttireGrid extends StatelessWidget { top: UiConstants.space2, left: UiConstants.space2, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: UiColors.destructive, // Red borderRadius: UiConstants.radiusSm, ), child: Text( t.staff_profile_attire.status.required, - style: UiTypography.body3m.copyWith( // 12px Medium -> Bold + style: UiTypography.body3m.copyWith( + // 12px Medium -> Bold fontWeight: FontWeight.bold, - fontSize: 9, + fontSize: 9, color: UiColors.white, ), ), @@ -97,11 +96,7 @@ class AttireGrid extends StatelessWidget { shape: BoxShape.circle, ), child: const Center( - child: Icon( - UiIcons.check, - color: UiColors.white, - size: 12, - ), + child: Icon(UiIcons.check, color: UiColors.white, size: 12), ), ), ), @@ -119,26 +114,34 @@ class AttireGrid extends StatelessWidget { height: 80, width: 80, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), image: DecorationImage( image: NetworkImage(item.imageUrl!), fit: BoxFit.cover, ), ), ) - : Icon( - _getIcon(item.iconName), + : const Icon( + UiIcons.shirt, size: 48, - color: UiColors.textPrimary, // Was charcoal + color: UiColors.iconSecondary, ), const SizedBox(height: UiConstants.space2), Text( item.label, textAlign: TextAlign.center, - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2m.textPrimary, ), + if (item.description != null) + Text( + item.description!, + textAlign: TextAlign.center, + style: UiTypography.body3r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], ), ), @@ -158,7 +161,9 @@ class AttireGrid extends StatelessWidget { border: Border.all( color: hasPhoto ? UiColors.primary : UiColors.border, ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -169,7 +174,9 @@ class AttireGrid extends StatelessWidget { height: 12, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(UiColors.primary), + valueColor: AlwaysStoppedAnimation( + UiColors.primary, + ), ), ) else if (hasPhoto) @@ -189,10 +196,12 @@ class AttireGrid extends StatelessWidget { isUploading ? '...' : hasPhoto - ? t.staff_profile_attire.status.added - : t.staff_profile_attire.status.add_photo, + ? t.staff_profile_attire.status.added + : t.staff_profile_attire.status.add_photo, style: UiTypography.body3m.copyWith( - color: hasPhoto ? UiColors.primary : UiColors.textSecondary, + color: hasPhoto + ? UiColors.primary + : UiColors.textSecondary, ), ), ], @@ -217,23 +226,4 @@ class AttireGrid extends StatelessWidget { ), ); } - - IconData _getIcon(String? name) { - switch (name) { - case 'footprints': - return UiIcons.footprints; - case 'scissors': - return UiIcons.scissors; - case 'user': - return UiIcons.user; - case 'shirt': - return UiIcons.shirt; - case 'hardHat': - return UiIcons.hardHat; - case 'chefHat': - return UiIcons.chefHat; - default: - return UiIcons.help; - } - } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart new file mode 100644 index 00000000..f0941d96 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -0,0 +1,134 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +class AttireItemCard extends StatelessWidget { + const AttireItemCard({ + super.key, + required this.item, + this.uploadedPhotoUrl, + this.isUploading = false, + required this.onTap, + }); + + final AttireItem item; + final String? uploadedPhotoUrl; + final bool isUploading; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final bool hasPhoto = item.photoUrl != null; + final String statusText = switch (item.verificationStatus) { + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', + AttireVerificationStatus.pending => 'Pending', + _ => hasPhoto ? 'Pending' : 'To Do', + }; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + image: DecorationImage( + image: NetworkImage( + item.imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: UiConstants.space4), + // details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: UiTypography.body1m.textPrimary), + if (item.description != null) ...[ + Text( + item.description!, + style: UiTypography.body2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: UiConstants.space2), + Row( + spacing: UiConstants.space2, + children: [ + if (item.isMandatory) + const UiChip( + label: 'Required', + size: UiChipSize.xSmall, + variant: UiChipVariant.destructive, + ), + if (isUploading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (hasPhoto) + UiChip( + label: statusText, + size: UiChipSize.xSmall, + variant: + item.verificationStatus == + AttireVerificationStatus.approved + ? UiChipVariant.primary + : UiChipVariant.secondary, + ), + ], + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + // Chevron or status + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 20), + if (!hasPhoto && !isUploading) + const Icon( + UiIcons.chevronRight, + color: UiColors.textInactive, + size: 24, + ) + else if (hasPhoto && !isUploading) + Icon( + item.verificationStatus == AttireVerificationStatus.approved + ? UiIcons.check + : UiIcons.clock, + color: + item.verificationStatus == + AttireVerificationStatus.approved + ? UiColors.textPrimary + : UiColors.textWarning, + size: 24, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart index c63a8cbe..36d4cba2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart @@ -1,3 +1,3 @@ -library staff_attire; +library; export 'src/attire_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml index 07a124c8..0a5ffcf0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path: ../../../../../design_system core_localization: path: ../../../../../core_localization + image_picker: ^1.2.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart index c8aab7be..7a00374c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart @@ -39,7 +39,6 @@ class EmergencyContactScreen extends StatelessWidget { body: BlocProvider( create: (context) => Modular.get(), child: BlocConsumer( - listener: (context, state) { if (state.status == EmergencyContactStatus.failure) { UiSnackbar.show( @@ -66,12 +65,12 @@ class EmergencyContactScreen extends StatelessWidget { const EmergencyContactInfoBanner(), const SizedBox(height: UiConstants.space6), ...state.contacts.asMap().entries.map( - (entry) => EmergencyContactFormItem( - index: entry.key, - contact: entry.value, - totalContacts: state.contacts.length, - ), - ), + (entry) => EmergencyContactFormItem( + index: entry.key, + contact: entry.value, + totalContacts: state.contacts.length, + ), + ), const EmergencyContactAddButton(), const SizedBox(height: UiConstants.space16), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart index c332ac74..2097d866 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart @@ -2,13 +2,17 @@ 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/emergency_contact_bloc.dart'; class EmergencyContactSaveButton extends StatelessWidget { const EmergencyContactSaveButton({super.key}); void _onSave(BuildContext context) { - context.read().add(EmergencyContactsSaved()); + BlocProvider.of( + context, + ).add(EmergencyContactsSaved()); } @override @@ -19,10 +23,13 @@ class EmergencyContactSaveButton extends StatelessWidget { if (state.status == EmergencyContactStatus.saved) { UiSnackbar.show( context, - message: t.staff.profile.menu_items.emergency_contact_page.save_success, + message: + t.staff.profile.menu_items.emergency_contact_page.save_success, type: UiSnackbarType.success, margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); + + Modular.to.toProfile(); } }, builder: (context, state) { @@ -36,8 +43,9 @@ class EmergencyContactSaveButton extends StatelessWidget { child: SafeArea( child: UiButton.primary( fullWidth: true, - onPressed: - state.isValid && !isLoading ? () => _onSave(context) : null, + onPressed: state.isValid && !isLoading + ? () => _onSave(context) + : null, child: isLoading ? const SizedBox( height: 20.0, @@ -49,7 +57,14 @@ class EmergencyContactSaveButton extends StatelessWidget { ), ), ) - : Text(t.staff.profile.menu_items.emergency_contact_page.save_continue), + : Text( + t + .staff + .profile + .menu_items + .emergency_contact_page + .save_continue, + ), ), ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart index 255f8554..aa3385b9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart @@ -4,7 +4,7 @@ class SaveExperienceArguments extends UseCaseArgument { final List industries; final List skills; - SaveExperienceArguments({ + const SaveExperienceArguments({ required this.industries, required this.skills, }); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart index 3a1e6515..20829532 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart @@ -124,7 +124,7 @@ class ExperienceBloc extends Bloc ) async { emit(state.copyWith(status: ExperienceStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final results = await Future.wait([getIndustries(), getSkills()]); @@ -189,7 +189,7 @@ class ExperienceBloc extends Bloc ) async { emit(state.copyWith(status: ExperienceStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { await saveExperience( SaveExperienceArguments( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart index d7a77c28..7b42e3d0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/experience_bloc.dart'; @@ -13,34 +14,57 @@ class ExperiencePage extends StatelessWidget { String _getIndustryLabel(dynamic node, Industry industry) { switch (industry) { - case Industry.hospitality: return node.hospitality; - case Industry.foodService: return node.food_service; - case Industry.warehouse: return node.warehouse; - case Industry.events: return node.events; - case Industry.retail: return node.retail; - case Industry.healthcare: return node.healthcare; - case Industry.other: return node.other; + case Industry.hospitality: + return node.hospitality; + case Industry.foodService: + return node.food_service; + case Industry.warehouse: + return node.warehouse; + case Industry.events: + return node.events; + case Industry.retail: + return node.retail; + case Industry.healthcare: + return node.healthcare; + case Industry.other: + return node.other; } } String _getSkillLabel(dynamic node, ExperienceSkill skill) { switch (skill) { - case ExperienceSkill.foodService: return node.food_service; - case ExperienceSkill.bartending: return node.bartending; - case ExperienceSkill.eventSetup: return node.event_setup; - case ExperienceSkill.hospitality: return node.hospitality; - case ExperienceSkill.warehouse: return node.warehouse; - case ExperienceSkill.customerService: return node.customer_service; - case ExperienceSkill.cleaning: return node.cleaning; - case ExperienceSkill.security: return node.security; - case ExperienceSkill.retail: return node.retail; - case ExperienceSkill.driving: return node.driving; - case ExperienceSkill.cooking: return node.cooking; - case ExperienceSkill.cashier: return node.cashier; - case ExperienceSkill.server: return node.server; - case ExperienceSkill.barista: return node.barista; - case ExperienceSkill.hostHostess: return node.host_hostess; - case ExperienceSkill.busser: return node.busser; + case ExperienceSkill.foodService: + return node.food_service; + case ExperienceSkill.bartending: + return node.bartending; + case ExperienceSkill.eventSetup: + return node.event_setup; + case ExperienceSkill.hospitality: + return node.hospitality; + case ExperienceSkill.warehouse: + return node.warehouse; + case ExperienceSkill.customerService: + return node.customer_service; + case ExperienceSkill.cleaning: + return node.cleaning; + case ExperienceSkill.security: + return node.security; + case ExperienceSkill.retail: + return node.retail; + case ExperienceSkill.driving: + return node.driving; + case ExperienceSkill.cooking: + return node.cooking; + case ExperienceSkill.cashier: + return node.cashier; + case ExperienceSkill.server: + return node.server; + case ExperienceSkill.barista: + return node.barista; + case ExperienceSkill.hostHostess: + return node.host_hostess; + case ExperienceSkill.busser: + return node.busser; } } @@ -51,39 +75,38 @@ class ExperiencePage extends StatelessWidget { return Scaffold( appBar: UiAppBar( title: i18n.title, - onLeadingPressed: () => Modular.to.pop(), + onLeadingPressed: () => Modular.to.toProfile(), ), body: BlocProvider( create: (context) => Modular.get(), child: BlocConsumer( listener: (context, state) { - if (state.status == ExperienceStatus.success) { - UiSnackbar.show( - context, - message: 'Experience saved successfully', - type: UiSnackbarType.success, - margin: const EdgeInsets.only( - bottom: 120, - left: UiConstants.space4, - right: UiConstants.space4, - ), - ); - Modular.to.pop(); - } else if (state.status == ExperienceStatus.failure) { - UiSnackbar.show( - context, - message: state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - type: UiSnackbarType.error, - margin: const EdgeInsets.only( - bottom: 120, - left: UiConstants.space4, - right: UiConstants.space4, - ), - ); - } - }, + if (state.status == ExperienceStatus.success) { + UiSnackbar.show( + context, + message: 'Experience saved successfully', + type: UiSnackbarType.success, + margin: const EdgeInsets.only( + bottom: 120, + left: UiConstants.space4, + right: UiConstants.space4, + ), + ); + } else if (state.status == ExperienceStatus.failure) { + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, + margin: const EdgeInsets.only( + bottom: 120, + left: UiConstants.space4, + right: UiConstants.space4, + ), + ); + } + }, builder: (context, state) { return Column( children: [ @@ -106,15 +129,15 @@ class ExperiencePage extends StatelessWidget { .map( (i) => UiChip( label: _getIndustryLabel(i18n.industries, i), - isSelected: - state.selectedIndustries.contains(i), - onTap: () => - BlocProvider.of(context) - .add(ExperienceIndustryToggled(i)), - variant: - state.selectedIndustries.contains(i) - ? UiChipVariant.primary - : UiChipVariant.secondary, + isSelected: state.selectedIndustries.contains( + i, + ), + onTap: () => BlocProvider.of( + context, + ).add(ExperienceIndustryToggled(i)), + variant: state.selectedIndustries.contains(i) + ? UiChipVariant.primary + : UiChipVariant.secondary, ), ) .toList(), @@ -133,15 +156,16 @@ class ExperiencePage extends StatelessWidget { .map( (s) => UiChip( label: _getSkillLabel(i18n.skills, s), - isSelected: - state.selectedSkills.contains(s.value), - onTap: () => - BlocProvider.of(context) - .add(ExperienceSkillToggled(s.value)), + isSelected: state.selectedSkills.contains( + s.value, + ), + onTap: () => BlocProvider.of( + context, + ).add(ExperienceSkillToggled(s.value)), variant: state.selectedSkills.contains(s.value) - ? UiChipVariant.primary - : UiChipVariant.secondary, + ? UiChipVariant.primary + : UiChipVariant.secondary, ), ) .toList(), @@ -177,10 +201,7 @@ class ExperiencePage extends StatelessWidget { spacing: UiConstants.space2, runSpacing: UiConstants.space2, children: customSkills.map((skill) { - return UiChip( - label: skill, - variant: UiChipVariant.accent, - ); + return UiChip(label: skill, variant: UiChipVariant.accent); }).toList(), ), ], @@ -202,8 +223,9 @@ class ExperiencePage extends StatelessWidget { child: UiButton.primary( onPressed: state.status == ExperienceStatus.loading ? null - : () => BlocProvider.of(context) - .add(ExperienceSubmitted()), + : () => BlocProvider.of( + context, + ).add(ExperienceSubmitted()), fullWidth: true, text: state.status == ExperienceStatus.loading ? null diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart index db83d59f..f3e354fd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart @@ -1,4 +1,4 @@ -library staff_profile_experience; +library; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart index b3d4a8b2..1f3f564f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart @@ -1,3 +1,4 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; @@ -29,6 +30,8 @@ class PersonalInfoBloc extends Bloc on(_onFieldChanged); on(_onAddressSelected); on(_onSubmitted); + on(_onLocationAdded); + on(_onLocationRemoved); add(const PersonalInfoLoadRequested()); } @@ -42,7 +45,7 @@ class PersonalInfoBloc extends Bloc ) async { emit(state.copyWith(status: PersonalInfoStatus.loading)); await handleError( - emit: emit, + emit: emit.call, action: () async { final Staff staff = await _getPersonalInfoUseCase(); @@ -93,7 +96,7 @@ class PersonalInfoBloc extends Bloc emit(state.copyWith(status: PersonalInfoStatus.saving)); await handleError( - emit: emit, + emit: emit.call, action: () async { final Staff updatedStaff = await _updatePersonalInfoUseCase( UpdatePersonalInfoParams( @@ -133,11 +136,48 @@ class PersonalInfoBloc extends Bloc PersonalInfoAddressSelected event, Emitter emit, ) { - // TODO: Implement Google Places logic if needed + // Legacy address selected รขโ‚ฌโ€œ no-op; use PersonalInfoLocationAdded instead. } - /// With _onPhotoUploadRequested and _onSaveRequested removed or renamed, - /// there are no errors pointing to them here. + /// Adds a location to the preferredLocations list (max 5, no duplicates). + void _onLocationAdded( + PersonalInfoLocationAdded event, + Emitter emit, + ) { + final dynamic raw = state.formValues['preferredLocations']; + final List current = _toStringList(raw); + + if (current.length >= 5) return; // max guard + if (current.contains(event.location)) return; // no duplicates + + final List updated = List.from(current)..add(event.location); + final Map updatedValues = Map.from(state.formValues) + ..['preferredLocations'] = updated; + + emit(state.copyWith(formValues: updatedValues)); + } + + /// Removes a location from the preferredLocations list. + void _onLocationRemoved( + PersonalInfoLocationRemoved event, + Emitter emit, + ) { + final dynamic raw = state.formValues['preferredLocations']; + final List current = _toStringList(raw); + + final List updated = List.from(current) + ..remove(event.location); + final Map updatedValues = Map.from(state.formValues) + ..['preferredLocations'] = updated; + + emit(state.copyWith(formValues: updatedValues)); + } + + List _toStringList(dynamic raw) { + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } @override void dispose() { @@ -145,3 +185,4 @@ class PersonalInfoBloc extends Bloc } } + diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart index a577287f..b6a73841 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart @@ -40,3 +40,21 @@ class PersonalInfoAddressSelected extends PersonalInfoEvent { @override List get props => [address]; } + +/// Event to add a preferred location. +class PersonalInfoLocationAdded extends PersonalInfoEvent { + const PersonalInfoLocationAdded({required this.location}); + final String location; + + @override + List get props => [location]; +} + +/// Event to remove a preferred location. +class PersonalInfoLocationRemoved extends PersonalInfoEvent { + const PersonalInfoLocationRemoved({required this.location}); + final String location; + + @override + List get props => [location]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart new file mode 100644 index 00000000..01b902c5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -0,0 +1,112 @@ +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'; + +/// Language selection page for staff profile. +/// +/// Displays available languages and allows the user to select their preferred +/// language. Changes are applied immediately via [LocaleBloc] and persisted. +/// Shows a snackbar when the language is successfully changed. +class LanguageSelectionPage extends StatelessWidget { + /// Creates a [LanguageSelectionPage]. + const LanguageSelectionPage({super.key}); + + String _getLocalizedLanguageName(AppLocale locale) { + switch (locale) { + case AppLocale.en: + return 'English'; + case AppLocale.es: + return 'Espaรฑol'; + } + } + + void _showLanguageChangedSnackbar(BuildContext context, String languageName) { + UiSnackbar.show( + context, + message: '${t.settings.change_language}: $languageName', + type: UiSnackbarType.success, + ); + + Modular.to + .pop(); // Close the language selection page after showing the snackbar + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.settings.change_language, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: Container(color: UiColors.border, height: 1.0), + ), + ), + body: SafeArea( + child: BlocBuilder( + builder: (BuildContext context, LocaleState state) { + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + _buildLanguageOption(context, locale: AppLocale.en), + const SizedBox(height: UiConstants.space4), + _buildLanguageOption(context, locale: AppLocale.es), + ], + ); + }, + ), + ), + ); + } + + Widget _buildLanguageOption( + BuildContext context, { + required AppLocale locale, + }) { + final String label = _getLocalizedLanguageName(locale); + // Check if this option is currently selected. + final AppLocale currentLocale = LocaleSettings.currentLocale; + final bool isSelected = currentLocale == locale; + + return InkWell( + onTap: () { + // Only proceed if selecting a different language + if (currentLocale != locale) { + Modular.get().add(ChangeLocale(locale.flutterLocale)); + _showLanguageChangedSnackbar(context, label); + } + }, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + horizontal: UiConstants.space4, + ), + decoration: BoxDecoration( + color: isSelected + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: isSelected + ? UiTypography.body1b.copyWith(color: UiColors.primary) + : UiTypography.body1r, + ), + if (isSelected) const Icon(UiIcons.check, color: UiColors.primary), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index 9349ffdb..501bb577 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -22,7 +22,7 @@ class PersonalInfoPage extends StatelessWidget { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.onboarding.personal_info; + final TranslationsStaffOnboardingPersonalInfoEn i18n = Translations.of(context).staff.onboarding.personal_info; return BlocProvider( create: (BuildContext context) => Modular.get(), child: BlocListener( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart new file mode 100644 index 00000000..32629cd0 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart @@ -0,0 +1,515 @@ +๏ปฟ// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +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:google_places_flutter/google_places_flutter.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_core/core.dart'; + +import '../blocs/personal_info_bloc.dart'; +import '../blocs/personal_info_event.dart'; +import '../blocs/personal_info_state.dart'; + +/// The maximum number of preferred locations a staff member can add. +const int _kMaxLocations = 5; + +/// Uber-style Preferred Locations editing page. +/// +/// Allows staff to search for US locations using the Google Places API, +/// add them as chips (max 5), and save back to their profile. +class PreferredLocationsPage extends StatefulWidget { + /// Creates a [PreferredLocationsPage]. + const PreferredLocationsPage({super.key}); + + @override + State createState() => _PreferredLocationsPageState(); +} + +class _PreferredLocationsPageState extends State { + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _onLocationSelected(Prediction prediction, PersonalInfoBloc bloc) { + final String description = prediction.description ?? ''; + if (description.isEmpty) return; + + bloc.add(PersonalInfoLocationAdded(location: description)); + + // Clear search field after selection + _searchController.clear(); + _searchFocusNode.unfocus(); + } + + void _removeLocation(String location, PersonalInfoBloc bloc) { + bloc.add(PersonalInfoLocationRemoved(location: location)); + } + + void _save(BuildContext context, PersonalInfoBloc bloc, PersonalInfoState state) { + bloc.add(const PersonalInfoFormSubmitted()); + } + + @override + Widget build(BuildContext context) { + final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + // Access the same PersonalInfoBloc singleton managed by the module. + final PersonalInfoBloc bloc = Modular.get(); + + return BlocProvider.value( + value: bloc, + child: BlocConsumer( + listener: (BuildContext context, PersonalInfoState state) { + if (state.status == PersonalInfoStatus.saved) { + UiSnackbar.show( + context, + message: i18n.preferred_locations.save_success, + type: UiSnackbarType.success, + ); + Navigator.of(context).pop(); + } else if (state.status == PersonalInfoStatus.error) { + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, PersonalInfoState state) { + final List locations = _currentLocations(state); + final bool atMax = locations.length >= _kMaxLocations; + final bool isSaving = state.status == PersonalInfoStatus.saving; + + return Scaffold( + backgroundColor: UiColors.background, + appBar: AppBar( + backgroundColor: UiColors.bgPopup, + elevation: 0, + leading: IconButton( + icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), + onPressed: () => Navigator.of(context).pop(), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ), + title: Text( + i18n.preferred_locations.title, + style: UiTypography.title1m.textPrimary, + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: Container(color: UiColors.border, height: 1.0), + ), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // รขโ€โ‚ฌรขโ€โ‚ฌ Description + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space3, + ), + child: Text( + i18n.preferred_locations.description, + style: UiTypography.body2r.textSecondary, + ), + ), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Search autocomplete field + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: _PlacesSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + hint: i18n.preferred_locations.search_hint, + enabled: !atMax && !isSaving, + onSelected: (Prediction p) => _onLocationSelected(p, bloc), + ), + ), + + // รขโ€โ‚ฌรขโ€โ‚ฌ "Max reached" banner + if (atMax) + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space2, + UiConstants.space5, + 0, + ), + child: Row( + children: [ + const Icon( + UiIcons.info, + size: 14, + color: UiColors.textWarning, + ), + const SizedBox(width: UiConstants.space1), + Text( + i18n.preferred_locations.max_reached, + style: UiTypography.footnote1r.textWarning, + ), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Section label + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Text( + i18n.preferred_locations.added_label, + style: UiTypography.titleUppercase3m.textSecondary, + ), + ), + + const SizedBox(height: UiConstants.space3), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Locations list / empty state + Expanded( + child: locations.isEmpty + ? _EmptyLocationsState(message: i18n.preferred_locations.empty_state) + : _LocationsList( + locations: locations, + isSaving: isSaving, + removeTooltip: i18n.preferred_locations.remove_tooltip, + onRemove: (String loc) => _removeLocation(loc, bloc), + ), + ), + + // รขโ€โ‚ฌรขโ€โ‚ฌ Save button + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: UiButton.primary( + text: i18n.preferred_locations.save_button, + fullWidth: true, + onPressed: isSaving ? null : () => _save(context, bloc, state), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + List _currentLocations(PersonalInfoState state) { + final dynamic raw = state.formValues['preferredLocations']; + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } +} + +// รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ +// Subwidgets +// รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ + +/// Google Places autocomplete search field, locked to US results. +class _PlacesSearchField extends StatelessWidget { + const _PlacesSearchField({ + required this.controller, + required this.focusNode, + required this.hint, + required this.onSelected, + this.enabled = true, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final String hint; + final bool enabled; + final void Function(Prediction) onSelected; + + @override + Widget build(BuildContext context) { + return GooglePlaceAutoCompleteTextField( + textEditingController: controller, + focusNode: focusNode, + googleAPIKey: AppConfig.googleMapsApiKey, + debounceTime: 400, + countries: const ['us'], + isLatLngRequired: false, + getPlaceDetailWithLatLng: onSelected, + itemClick: (Prediction prediction) { + controller.text = prediction.description ?? ''; + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + onSelected(prediction); + }, + inputDecoration: InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textSecondary, + prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary), + onPressed: controller.clear, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.primary, width: 1.5), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), + ), + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + filled: true, + ), + textStyle: UiTypography.body2r.textPrimary, + itemBuilder: (BuildContext context, int index, Prediction prediction) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space2, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4.0), + ), + child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _mainText(prediction.description ?? ''), + style: UiTypography.body2m.textPrimary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_subText(prediction.description ?? '').isNotEmpty) + Text( + _subText(prediction.description ?? ''), + style: UiTypography.footnote1r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Extracts text before first comma as the primary line. + String _mainText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(0, commaIndex) : description; + } + + /// Extracts text after first comma as the secondary line. + String _subText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : ''; + } +} + +/// The scrollable list of location chips. +class _LocationsList extends StatelessWidget { + const _LocationsList({ + required this.locations, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final List locations; + final bool isSaving; + final String removeTooltip; + final void Function(String) onRemove; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + itemCount: locations.length, + separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2), + itemBuilder: (BuildContext context, int index) { + final String location = locations[index]; + return _LocationChip( + label: location, + index: index + 1, + total: locations.length, + isSaving: isSaving, + removeTooltip: removeTooltip, + onRemove: () => onRemove(location), + ); + }, + ); + } +} + +/// A single location row with pin icon, label, and remove button. +class _LocationChip extends StatelessWidget { + const _LocationChip({ + required this.label, + required this.index, + required this.total, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final String label; + final int index; + final int total; + final bool isSaving; + final String removeTooltip; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + // Index badge + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Text( + '$index', + style: UiTypography.footnote1m.copyWith(color: UiColors.primary), + ), + ), + const SizedBox(width: UiConstants.space3), + + // Pin icon + const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + + // Location text + Expanded( + child: Text( + label, + style: UiTypography.body2m.textPrimary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + // Remove button + if (!isSaving) + Tooltip( + message: removeTooltip, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space1), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Shows when no locations have been added yet. +class _EmptyLocationsState extends StatelessWidget { + const _EmptyLocationsState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary), + ), + const SizedBox(height: UiConstants.space4), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ); + } +} + diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart index 41ed320d..944f5297 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart @@ -34,26 +34,22 @@ class PersonalInfoContent extends StatefulWidget { class _PersonalInfoContentState extends State { late final TextEditingController _emailController; late final TextEditingController _phoneController; - late final TextEditingController _locationsController; @override void initState() { super.initState(); _emailController = TextEditingController(text: widget.staff.email); _phoneController = TextEditingController(text: widget.staff.phone ?? ''); - _locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? ''); // Listen to changes and update BLoC _emailController.addListener(_onEmailChanged); _phoneController.addListener(_onPhoneChanged); - _locationsController.addListener(_onAddressChanged); } @override void dispose() { _emailController.dispose(); _phoneController.dispose(); - _locationsController.dispose(); super.dispose(); } @@ -76,23 +72,6 @@ class _PersonalInfoContentState extends State { ); } - void _onAddressChanged() { - // Split the comma-separated string into a list for storage - // The backend expects List (JSON/List) for preferredLocations - final List locations = _locationsController.text - .split(',') - .map((String e) => e.trim()) - .where((String e) => e.isNotEmpty) - .toList(); - - context.read().add( - PersonalInfoFieldChanged( - field: 'preferredLocations', - value: locations, - ), - ); - } - void _handleSave() { context.read().add(const PersonalInfoFormSubmitted()); } @@ -129,7 +108,7 @@ class _PersonalInfoContentState extends State { email: widget.staff.email, emailController: _emailController, phoneController: _phoneController, - locationsController: _locationsController, + currentLocations: _toStringList(state.formValues['preferredLocations']), enabled: !isSaving, ), const SizedBox(height: UiConstants.space16), // Space for bottom button @@ -147,4 +126,10 @@ class _PersonalInfoContentState extends State { }, ); } -} \ No newline at end of file + + List _toStringList(dynamic raw) { + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart index 6ae1fc46..be7edfd8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; - +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; /// A form widget containing all personal information fields. /// -/// Includes read-only fields for full name and email, -/// and editable fields for phone and address. +/// Includes read-only fields for full name, +/// and editable fields for email and phone. +/// The Preferred Locations row navigates to a dedicated Uber-style page. /// Uses only design system tokens for colors, typography, and spacing. class PersonalInfoForm extends StatelessWidget { @@ -17,7 +19,7 @@ class PersonalInfoForm extends StatelessWidget { required this.email, required this.emailController, required this.phoneController, - required this.locationsController, + required this.currentLocations, this.enabled = true, }); /// The staff member's full name (read-only). @@ -32,8 +34,8 @@ class PersonalInfoForm extends StatelessWidget { /// Controller for the phone number field. final TextEditingController phoneController; - /// Controller for the address field. - final TextEditingController locationsController; + /// Current preferred locations list to show in the summary row. + final List currentLocations; /// Whether the form fields are enabled for editing. final bool enabled; @@ -41,6 +43,9 @@ class PersonalInfoForm extends StatelessWidget { @override Widget build(BuildContext context) { final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + final String locationSummary = currentLocations.isEmpty + ? i18n.locations_summary_none + : currentLocations.join(', '); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,14 +72,27 @@ class PersonalInfoForm extends StatelessWidget { controller: phoneController, hint: i18n.phone_hint, enabled: enabled, + keyboardType: TextInputType.phone, ), const SizedBox(height: UiConstants.space4), _FieldLabel(text: i18n.locations_label), const SizedBox(height: UiConstants.space2), - _EditableField( - controller: locationsController, + // Uber-style tappable row โ†’ navigates to PreferredLocationsPage + _TappableRow( + value: locationSummary, hint: i18n.locations_hint, + icon: UiIcons.mapPin, + enabled: enabled, + onTap: enabled + ? () => Modular.to.pushNamed(StaffPaths.preferredLocations) + : null, + ), + const SizedBox(height: UiConstants.space4), + + const _FieldLabel(text: 'Language'), + const SizedBox(height: UiConstants.space2), + _LanguageSelector( enabled: enabled, ), ], @@ -82,10 +100,121 @@ class PersonalInfoForm extends StatelessWidget { } } -/// A label widget for form fields. -/// A label widget for form fields. -class _FieldLabel extends StatelessWidget { +/// An Uber-style tappable row for navigating to a sub-page editor. +/// Displays the current value (or hint if empty) and a chevron arrow. +class _TappableRow extends StatelessWidget { + const _TappableRow({ + required this.value, + required this.hint, + required this.icon, + this.onTap, + this.enabled = true, + }); + final String value; + final String hint; + final IconData icon; + final VoidCallback? onTap; + final bool enabled; + + @override + Widget build(BuildContext context) { + final bool hasValue = value.isNotEmpty; + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all( + color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + hasValue ? value : hint, + style: hasValue + ? UiTypography.body2r.textPrimary + : UiTypography.body2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 18, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ); + } +} + +/// A language selector widget that displays the current language and navigates to language selection page. +class _LanguageSelector extends StatelessWidget { + const _LanguageSelector({ + this.enabled = true, + }); + + final bool enabled; + + @override + Widget build(BuildContext context) { + final String currentLocale = Localizations.localeOf(context).languageCode; + final String languageName = currentLocale == 'es' ? 'Espaรฑol' : 'English'; + + return GestureDetector( + onTap: enabled + ? () => Modular.to.pushNamed(StaffPaths.languageSelection) + : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all( + color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + languageName, + style: UiTypography.body2r.textPrimary, + ), + ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 18, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { const _FieldLabel({required this.text}); final String text; @@ -93,13 +222,11 @@ class _FieldLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text, - style: UiTypography.body2m.textPrimary, + style: UiTypography.titleUppercase3m.textSecondary, ); } } -/// A read-only field widget for displaying non-editable information. -/// A read-only field widget for displaying non-editable information. class _ReadOnlyField extends StatelessWidget { const _ReadOnlyField({required this.value}); final String value; @@ -119,14 +246,12 @@ class _ReadOnlyField extends StatelessWidget { ), child: Text( value, - style: UiTypography.body2r.textPrimary, + style: UiTypography.body2r.textInactive, ), ); } } -/// An editable text field widget. -/// An editable text field widget. class _EditableField extends StatelessWidget { const _EditableField({ required this.controller, @@ -168,7 +293,7 @@ class _EditableField extends StatelessWidget { borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderSide: const BorderSide(color: UiColors.primary), ), - fillColor: UiColors.bgPopup, + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, filled: true, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart index 47c80748..d9617e9b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'data/repositories/personal_info_repository_impl.dart'; import 'domain/repositories/personal_info_repository_interface.dart'; @@ -7,6 +8,8 @@ import 'domain/usecases/get_personal_info_usecase.dart'; import 'domain/usecases/update_personal_info_usecase.dart'; import 'presentation/blocs/personal_info_bloc.dart'; import 'presentation/pages/personal_info_page.dart'; +import 'presentation/pages/language_selection_page.dart'; +import 'presentation/pages/preferred_locations_page.dart'; /// The entry module for the Staff Profile Info feature. /// @@ -23,7 +26,8 @@ class StaffProfileInfoModule extends Module { void binds(Injector i) { // Repository i.addLazySingleton( - PersonalInfoRepositoryImpl.new); + PersonalInfoRepositoryImpl.new, + ); // Use Cases - delegate business logic to repository i.addLazySingleton( @@ -45,13 +49,25 @@ class StaffProfileInfoModule extends Module { @override void routes(RouteManager r) { r.child( - '/personal-info', + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.onboardingPersonalInfo, + ), child: (BuildContext context) => const PersonalInfoPage(), ); - // Alias with trailing slash to be tolerant of external deep links r.child( - '/personal-info/', - child: (BuildContext context) => const PersonalInfoPage(), + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.languageSelection, + ), + child: (BuildContext context) => const LanguageSelectionPage(), + ); + r.child( + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.preferredLocations, + ), + child: (BuildContext context) => const PreferredLocationsPage(), ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml index ef8602e7..a3853419 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml @@ -30,6 +30,8 @@ dependencies: firebase_auth: any firebase_data_connect: any + google_places_flutter: ^2.1.1 + http: ^1.2.2 dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json new file mode 100644 index 00000000..6b726e27 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json @@ -0,0 +1,53 @@ +[ + { + "category": "Getting Started", + "questions": [ + { + "q": "How do I apply for shifts?", + "a": "Browse available shifts on the Shifts tab and tap \"Accept\" on any shift that interests you. Once confirmed, you'll receive all the details you need." + }, + { + "q": "How do I get paid?", + "a": "Payments are processed weekly via direct deposit to your linked bank account. You can view your earnings in the Payments section." + }, + { + "q": "What if I need to cancel a shift?", + "a": "You can cancel a shift up to 24 hours before it starts without penalty. Late cancellations may affect your reliability score." + } + ] + }, + { + "category": "Shifts & Work", + "questions": [ + { + "q": "How do I clock in?", + "a": "Use the Clock In feature on the home screen when you arrive at your shift. Make sure location services are enabled for verification." + }, + { + "q": "What should I wear?", + "a": "Check the shift details for dress code requirements. You can manage your wardrobe in the Attire section of your profile." + }, + { + "q": "Who do I contact if I'm running late?", + "a": "Use the \"Running Late\" feature in the app to notify the client. You can also message the shift manager directly." + } + ] + }, + { + "category": "Payments & Earnings", + "questions": [ + { + "q": "When do I get paid?", + "a": "Payments are processed every Friday for shifts completed the previous week. Funds typically arrive within 1-2 business days." + }, + { + "q": "How do I update my bank account?", + "a": "Go to Profile > Finance > Bank Account to add or update your banking information." + }, + { + "q": "Where can I find my tax documents?", + "a": "Tax documents (1099) are available in Profile > Compliance > Tax Documents by January 31st each year." + } + ] + } +] diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart new file mode 100644 index 00000000..4bcc2ccd --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import '../../domain/entities/faq_category.dart'; +import '../../domain/entities/faq_item.dart'; +import '../../domain/repositories/faqs_repository_interface.dart'; + +/// Data layer implementation of FAQs repository +/// +/// Handles loading FAQs from app assets (JSON file) +class FaqsRepositoryImpl implements FaqsRepositoryInterface { + /// Private cache for FAQs to avoid reloading from assets multiple times + List? _cachedFaqs; + + @override + Future> getFaqs() async { + try { + // Return cached FAQs if available + if (_cachedFaqs != null) { + return _cachedFaqs!; + } + + // Load FAQs from JSON asset + final String faqsJson = await rootBundle.loadString( + 'packages/staff_faqs/lib/src/assets/faqs/faqs.json', + ); + + // Parse JSON + final List decoded = jsonDecode(faqsJson) as List; + + // Convert to domain entities + _cachedFaqs = decoded.map((dynamic item) { + final Map category = item as Map; + final String categoryName = category['category'] as String; + final List questionsData = + category['questions'] as List; + + final List questions = questionsData.map((dynamic q) { + final Map questionMap = q as Map; + return FaqItem( + question: questionMap['q'] as String, + answer: questionMap['a'] as String, + ); + }).toList(); + + return FaqCategory( + category: categoryName, + questions: questions, + ); + }).toList(); + + return _cachedFaqs!; + } catch (e) { + // Return empty list on error + return []; + } + } + + @override + Future> searchFaqs(String query) async { + try { + // Get all FAQs first + final List allFaqs = await getFaqs(); + + if (query.isEmpty) { + return allFaqs; + } + + final String lowerQuery = query.toLowerCase(); + + // Filter categories based on matching questions + final List filtered = allFaqs + .map((FaqCategory category) { + // Filter questions that match the query + final List matchingQuestions = + category.questions.where((FaqItem item) { + final String questionLower = item.question.toLowerCase(); + final String answerLower = item.answer.toLowerCase(); + return questionLower.contains(lowerQuery) || + answerLower.contains(lowerQuery); + }).toList(); + + // Only include category if it has matching questions + if (matchingQuestions.isNotEmpty) { + return FaqCategory( + category: category.category, + questions: matchingQuestions, + ); + } + return null; + }) + .whereType() + .toList(); + + return filtered; + } catch (e) { + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart new file mode 100644 index 00000000..c33b52de --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; + +import 'faq_item.dart'; + +/// Entity representing an FAQ category with its questions +class FaqCategory extends Equatable { + + const FaqCategory({ + required this.category, + required this.questions, + }); + /// The category name (e.g., "Getting Started", "Shifts & Work") + final String category; + + /// List of FAQ items in this category + final List questions; + + @override + List get props => [category, questions]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart new file mode 100644 index 00000000..e00f8de1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +/// Entity representing a single FAQ question and answer +class FaqItem extends Equatable { + + const FaqItem({ + required this.question, + required this.answer, + }); + /// The question text + final String question; + + /// The answer text + final String answer; + + @override + List get props => [question, answer]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart new file mode 100644 index 00000000..887ea0d1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart @@ -0,0 +1,11 @@ +import '../entities/faq_category.dart'; + +/// Interface for FAQs repository operations +abstract class FaqsRepositoryInterface { + /// Fetch all FAQ categories with their questions + Future> getFaqs(); + + /// Search FAQs by query string + /// Returns categories that contain matching questions + Future> searchFaqs(String query); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart new file mode 100644 index 00000000..4dc83c12 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart @@ -0,0 +1,19 @@ +import '../entities/faq_category.dart'; +import '../repositories/faqs_repository_interface.dart'; + +/// Use case to retrieve all FAQs +class GetFaqsUseCase { + + GetFaqsUseCase(this._repository); + final FaqsRepositoryInterface _repository; + + /// Execute the use case to get all FAQ categories + Future> call() async { + try { + return await _repository.getFaqs(); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart new file mode 100644 index 00000000..ef0ae5c1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart @@ -0,0 +1,27 @@ +import '../entities/faq_category.dart'; +import '../repositories/faqs_repository_interface.dart'; + +/// Parameters for search FAQs use case +class SearchFaqsParams { + + SearchFaqsParams({required this.query}); + /// Search query string + final String query; +} + +/// Use case to search FAQs by query +class SearchFaqsUseCase { + + SearchFaqsUseCase(this._repository); + final FaqsRepositoryInterface _repository; + + /// Execute the use case to search FAQs + Future> call(SearchFaqsParams params) async { + try { + return await _repository.searchFaqs(params.query); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart new file mode 100644 index 00000000..72dbb262 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart @@ -0,0 +1,76 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/faq_category.dart'; +import '../../domain/usecases/get_faqs_usecase.dart'; +import '../../domain/usecases/search_faqs_usecase.dart'; + +part 'faqs_event.dart'; +part 'faqs_state.dart'; + +/// BLoC managing FAQs state +class FaqsBloc extends Bloc { + + FaqsBloc({ + required GetFaqsUseCase getFaqsUseCase, + required SearchFaqsUseCase searchFaqsUseCase, + }) : _getFaqsUseCase = getFaqsUseCase, + _searchFaqsUseCase = searchFaqsUseCase, + super(const FaqsState()) { + on(_onFetchFaqs); + on(_onSearchFaqs); + } + final GetFaqsUseCase _getFaqsUseCase; + final SearchFaqsUseCase _searchFaqsUseCase; + + Future _onFetchFaqs( + FetchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null)); + + try { + final List categories = await _getFaqsUseCase.call(); + emit( + state.copyWith( + isLoading: false, + categories: categories, + searchQuery: '', + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to load FAQs', + ), + ); + } + } + + Future _onSearchFaqs( + SearchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null, searchQuery: event.query)); + + try { + final List results = await _searchFaqsUseCase.call( + SearchFaqsParams(query: event.query), + ); + emit( + state.copyWith( + isLoading: false, + categories: results, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to search FAQs', + ), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart new file mode 100644 index 00000000..a2094e38 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart @@ -0,0 +1,25 @@ +part of 'faqs_bloc.dart'; + +/// Base class for FAQs BLoC events +abstract class FaqsEvent extends Equatable { + const FaqsEvent(); + + @override + List get props => []; +} + +/// Event to fetch all FAQs +class FetchFaqsEvent extends FaqsEvent { + const FetchFaqsEvent(); +} + +/// Event to search FAQs by query +class SearchFaqsEvent extends FaqsEvent { + + const SearchFaqsEvent({required this.query}); + /// Search query string + final String query; + + @override + List get props => [query]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart new file mode 100644 index 00000000..906ffc2d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart @@ -0,0 +1,46 @@ +part of 'faqs_bloc.dart'; + +/// State for FAQs BLoC +class FaqsState extends Equatable { + + const FaqsState({ + this.categories = const [], + this.isLoading = false, + this.searchQuery = '', + this.error, + }); + /// List of FAQ categories currently displayed + final List categories; + + /// Whether FAQs are currently loading + final bool isLoading; + + /// Current search query + final String searchQuery; + + /// Error message, if any + final String? error; + + /// Create a copy with optional field overrides + FaqsState copyWith({ + List? categories, + bool? isLoading, + String? searchQuery, + String? error, + }) { + return FaqsState( + categories: categories ?? this.categories, + isLoading: isLoading ?? this.isLoading, + searchQuery: searchQuery ?? this.searchQuery, + error: error, + ); + } + + @override + List get props => [ + categories, + isLoading, + searchQuery, + error, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart new file mode 100644 index 00000000..1c99a9ab --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart @@ -0,0 +1,32 @@ +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 '../blocs/faqs_bloc.dart'; +import '../widgets/faqs_widget.dart'; + +/// Page displaying frequently asked questions +class FaqsPage extends StatelessWidget { + const FaqsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_faqs.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => + Modular.get()..add(const FetchFaqsEvent()), + child: const Stack(children: [FaqsWidget()]), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart new file mode 100644 index 00000000..bda66591 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -0,0 +1,194 @@ +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:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; + +/// Widget displaying FAQs with search functionality and accordion items +class FaqsWidget extends StatefulWidget { + const FaqsWidget({super.key}); + + @override + State createState() => _FaqsWidgetState(); +} + +class _FaqsWidgetState extends State { + late TextEditingController _searchController; + final Map _openItems = {}; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _toggleItem(String key) { + setState(() { + _openItems[key] = !(_openItems[key] ?? false); + }); + } + + void _onSearchChanged(String value) { + if (value.isEmpty) { + context.read().add(const FetchFaqsEvent()); + } else { + context.read().add(SearchFaqsEvent(query: value)); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, FaqsState state) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), + child: Column( + children: [ + // Search Bar + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: t.staff_faqs.search_placeholder, + hintStyle: const TextStyle(color: UiColors.textPlaceholder), + prefixIcon: const Icon( + UiIcons.search, + color: UiColors.textSecondary, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(height: 24), + + // FAQ List or Empty State + if (state.isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: CircularProgressIndicator(), + ) + else if (state.categories.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + children: [ + const Icon( + UiIcons.helpCircle, + size: 48, + color: UiColors.textSecondary, + ), + const SizedBox(height: 12), + Text( + t.staff_faqs.no_results, + style: const TextStyle(color: UiColors.textSecondary), + ), + ], + ), + ) + else + ...state.categories.asMap().entries.map(( + MapEntry entry, + ) { + final int catIndex = entry.key; + final dynamic categoryItem = entry.value; + final String categoryName = categoryItem.category; + final List questions = categoryItem.questions; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + categoryName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + ...questions.asMap().entries.map(( + MapEntry qEntry, + ) { + final int qIndex = qEntry.key; + final dynamic questionItem = qEntry.value; + final String key = '$catIndex-$qIndex'; + final bool isOpen = _openItems[key] ?? false; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + InkWell( + onTap: () => _toggleItem(key), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + questionItem.question, + style: UiTypography.body1r, + ), + ), + Icon( + isOpen + ? UiIcons.chevronUp + : UiIcons.chevronDown, + color: UiColors.textSecondary, + size: 20, + ), + ], + ), + ), + ), + if (isOpen) + Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16, + ), + child: Text( + questionItem.answer, + style: UiTypography.body1r.textSecondary, + ), + ), + ], + ), + ); + }), + const SizedBox(height: 12), + ], + ); + }), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart new file mode 100644 index 00000000..6faf7c3a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart @@ -0,0 +1,52 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import 'data/repositories_impl/faqs_repository_impl.dart'; +import 'domain/repositories/faqs_repository_interface.dart'; +import 'domain/usecases/get_faqs_usecase.dart'; +import 'domain/usecases/search_faqs_usecase.dart'; +import 'presentation/blocs/faqs_bloc.dart'; +import 'presentation/pages/faqs_page.dart'; + +/// Module for FAQs feature +/// +/// Provides: +/// - Dependency injection for repositories, use cases, and BLoCs +/// - Route definitions delegated to core routing +class FaqsModule extends Module { + @override + void binds(Injector i) { + // Repository + i.addSingleton( + () => FaqsRepositoryImpl(), + ); + + // Use Cases + i.addSingleton( + () => GetFaqsUseCase( + i(), + ), + ); + i.addSingleton( + () => SearchFaqsUseCase( + i(), + ), + ); + + // BLoC + i.add( + () => FaqsBloc( + getFaqsUseCase: i(), + searchFaqsUseCase: i(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + StaffPaths.childRoute(StaffPaths.faqs, StaffPaths.faqs), + child: (_) => const FaqsPage(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart new file mode 100644 index 00000000..edbc96bb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart @@ -0,0 +1,4 @@ +library; + +export 'src/staff_faqs_module.dart'; +export 'src/presentation/pages/faqs_page.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml new file mode 100644 index 00000000..e50b0511 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml @@ -0,0 +1,29 @@ +name: staff_faqs +description: Frequently Asked Questions feature for staff application. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + + # Architecture Packages + krow_core: + path: ../../../../../core + design_system: + path: ../../../../../design_system + core_localization: + path: ../../../../../core_localization + +flutter: + uses-material-design: true + assets: + - lib/src/assets/faqs/ diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt new file mode 100644 index 00000000..b632873f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/privacy_policy.txt @@ -0,0 +1,119 @@ +PRIVACY POLICY + +Effective Date: February 18, 2026 + +1. INTRODUCTION + +Krow Workforce ("we," "us," "our," or "the App") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and otherwise process your personal information through our mobile application and related services. + +2. INFORMATION WE COLLECT + +2.1 Information You Provide Directly: +- Account information: name, email address, phone number, password +- Profile information: photo, bio, skills, experience, certifications +- Location data: work location preferences and current location (when enabled) +- Payment information: bank account details, tax identification numbers +- Communication data: messages, support inquiries, feedback + +2.2 Information Collected Automatically: +- Device information: device type, operating system, device identifiers +- Usage data: features accessed, actions taken, time and duration of activities +- Log data: IP address, browser type, pages visited, errors encountered +- Location data: approximate location based on IP address (always) +- Precise location: only when Location Sharing is enabled + +2.3 Information from Third Parties: +- Background check services: verification results +- Banking partners: account verification information +- Payment processors: transaction information + +3. HOW WE USE YOUR INFORMATION + +We use your information to: +- Create and maintain your account +- Process payments and verify employment eligibility +- Improve and optimize our services +- Send you important notifications and updates +- Provide customer support +- Prevent fraud and ensure security +- Comply with legal obligations +- Conduct analytics and research +- Match you with appropriate work opportunities +- Communicate promotional offers (with your consent) + +4. LOCATION DATA & PRIVACY SETTINGS + +4.1 Location Sharing: +You can control location sharing through Privacy Settings: +- Disabled (default): Your approximate location is based on IP address only +- Enabled: Precise location data is collected for better job matching + +4.2 Your Control: +You may enable or disable precise location sharing at any time in the Privacy & Security section of your profile. + +5. DATA RETENTION + +We retain your personal information for as long as: +- Your account is active, plus +- An additional period as required by law or for business purposes + +You may request deletion of your account and associated data by contacting support@krow.com. + +6. DATA SECURITY + +We implement appropriate technical and organizational measures to protect your personal information from unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet is 100% secure. + +7. SHARING OF INFORMATION + +We do not sell your personal information. We may share information with: +- Service providers and contractors: who process data on our behalf +- Employers and clients: limited information needed for job matching +- Legal authorities: when required by law +- Business partners: with your explicit consent +- Other users: your name, skills, and ratings (as needed for job matching) + +8. YOUR PRIVACY RIGHTS + +8.1 Access and Correction: +You have the right to access, review, and request correction of your personal information. + +8.2 Data Portability: +You may request a copy of your personal data in a portable format. + +8.3 Deletion: +You may request deletion of your account and personal information, subject to legal obligations. + +8.4 Opt-Out: +You may opt out of marketing communications and certain data processing activities. + +9. CHILDREN'S PRIVACY + +Our App is not intended for individuals under 18 years of age. We do not knowingly collect personal information from children. If we become aware that we have collected information from a child, we will take steps to delete such information immediately. + +10. THIRD-PARTY LINKS + +Our App may contain links to third-party websites. We are not responsible for the privacy practices of these external sites. We encourage you to review their privacy policies. + +11. INTERNATIONAL DATA TRANSFERS + +Your information may be transferred to, stored in, and processed in countries other than your country of residence. These countries may have data protection laws different from your home country. + +12. CHANGES TO THIS POLICY + +We may update this Privacy Policy from time to time. We will notify you of significant changes via email or through the App. Your continued use of the App constitutes your acceptance of the updated Privacy Policy. + +13. CONTACT US + +If you have questions about this Privacy Policy or your personal information, please contact us at: + +Email: privacy@krow.com +Address: Krow Workforce, [Company Address] +Phone: [Support Phone Number] + +14. CALIFORNIA PRIVACY RIGHTS (CCPA) + +If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA). Please visit our CCPA Rights page or contact privacy@krow.com for more information. + +15. EUROPEAN PRIVACY RIGHTS (GDPR) + +If you are in the European Union, you have rights under the General Data Protection Regulation (GDPR). These include the right to access, rectification, erasure, and data portability. Contact privacy@krow.com to exercise these rights. diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt new file mode 100644 index 00000000..818cbe06 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/assets/legal/terms_of_service.txt @@ -0,0 +1,61 @@ +TERMS OF SERVICE + +Effective Date: February 18, 2026 + +1. ACCEPTANCE OF TERMS + +By accessing and using the Krow Workforce application ("the App"), you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to abide by the above, please do not use this service. + +2. USE LICENSE + +Permission is granted to temporarily download one copy of the materials (information or software) on Krow Workforce's App for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title, and under this license you may not: + +a) Modifying or copying the materials +b) Using the materials for any commercial purpose or for any public display +c) Attempting to reverse engineer, disassemble, or decompile any software contained on the App +d) Removing any copyright or other proprietary notations from the materials +e) Transferring the materials to another person or "mirroring" the materials on any other server + +3. DISCLAIMER + +The materials on Krow Workforce's App are provided on an "as is" basis. Krow Workforce makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights. + +4. LIMITATIONS + +In no event shall Krow Workforce or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on Krow Workforce's App, even if Krow Workforce or a Krow Workforce authorized representative has been notified orally or in writing of the possibility of such damage. + +5. ACCURACY OF MATERIALS + +The materials appearing on Krow Workforce's App could include technical, typographical, or photographic errors. Krow Workforce does not warrant that any of the materials on its App are accurate, complete, or current. Krow Workforce may make changes to the materials contained on its App at any time without notice. + +6. MATERIALS DISCLAIMER + +Krow Workforce has not reviewed all of the sites linked to its App and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by Krow Workforce of the site. Use of any such linked website is at the user's own risk. + +7. MODIFICATIONS + +Krow Workforce may revise these terms of service for its App at any time without notice. By using this App, you are agreeing to be bound by the then current version of these terms of service. + +8. GOVERNING LAW + +These terms and conditions are governed by and construed in accordance with the laws of the jurisdiction in which Krow Workforce is located, and you irrevocably submit to the exclusive jurisdiction of the courts in that location. + +9. LIMITATION OF LIABILITY + +In no case shall Krow Workforce, its staff, or other contributors be liable for any indirect, incidental, consequential, special, or punitive damages arising out of or relating to the use of the App. + +10. USER CONTENT + +You grant Krow Workforce a non-exclusive, royalty-free, perpetual, and irrevocable right to use any content you provide to us, including but not limited to text, images, and information, in any media or format and for any purpose consistent with our business. + +11. INDEMNIFICATION + +You agree to indemnify and hold harmless Krow Workforce and its staff from any and all claims, damages, losses, costs, and expenses, including attorney's fees, arising out of or resulting from your use of the App or violation of these terms. + +12. TERMINATION + +Krow Workforce reserves the right to terminate your account and access to the App at any time, in its sole discretion, for any reason or no reason, with or without notice. + +13. CONTACT INFORMATION + +If you have any questions about these Terms of Service, please contact us at support@krow.com. diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart new file mode 100644 index 00000000..66225fc4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart @@ -0,0 +1,92 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/services.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import '../../domain/repositories/privacy_settings_repository_interface.dart'; + +/// Data layer implementation of privacy settings repository +/// +/// Handles all backend communication for privacy settings via Data Connect, +/// and loads legal documents from app assets +class PrivacySettingsRepositoryImpl + implements PrivacySettingsRepositoryInterface { + PrivacySettingsRepositoryImpl(this._service); + + final DataConnectService _service; + + @override + Future getProfileVisibility() async { + return _service.run(() async { + // Get current user ID + final String staffId = await _service.getStaffId(); + + // Call Data Connect query: getStaffProfileVisibility + final fdc.QueryResult< + GetStaffProfileVisibilityData, + GetStaffProfileVisibilityVariables + > + response = await _service.connector + .getStaffProfileVisibility(staffId: staffId) + .execute(); + + // Return the profile visibility status from the first result + if (response.data.staff != null) { + return response.data.staff?.isProfileVisible ?? true; + } + + // Default to visible if no staff record found + return true; + }); + } + + @override + Future updateProfileVisibility(bool isVisible) async { + return _service.run(() async { + // Get staff ID for the current user + final String staffId = await _service.getStaffId(); + + // Call Data Connect mutation: UpdateStaffProfileVisibility + await _service.connector + .updateStaffProfileVisibility( + id: staffId, + isProfileVisible: isVisible, + ) + .execute(); + + // Return the requested visibility state + return isVisible; + }); + } + + @override + Future getTermsOfService() async { + return _service.run(() async { + try { + // Load from package asset path + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/terms_of_service.txt', + ); + } catch (e) { + // Final fallback if asset not found + print('Error loading terms of service: $e'); + return 'Terms of Service - Content unavailable. Please contact support@krow.com'; + } + }); + } + + @override + Future getPrivacyPolicy() async { + return _service.run(() async { + try { + // Load from package asset path + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/privacy_policy.txt', + ); + } catch (e) { + // Final fallback if asset not found + print('Error loading privacy policy: $e'); + return 'Privacy Policy - Content unavailable. Please contact privacy@krow.com'; + } + }); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart new file mode 100644 index 00000000..3dfe9416 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/entities/privacy_settings_entity.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +/// Privacy settings entity representing user privacy preferences +class PrivacySettingsEntity extends Equatable { + + const PrivacySettingsEntity({ + required this.locationSharing, + this.updatedAt, + }); + /// Whether location sharing during shifts is enabled + final bool locationSharing; + + /// The timestamp when these settings were last updated + final DateTime? updatedAt; + + /// Create a copy with optional field overrides + PrivacySettingsEntity copyWith({ + bool? locationSharing, + DateTime? updatedAt, + }) { + return PrivacySettingsEntity( + locationSharing: locationSharing ?? this.locationSharing, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [locationSharing, updatedAt]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart new file mode 100644 index 00000000..8057a76e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/repositories/privacy_settings_repository_interface.dart @@ -0,0 +1,16 @@ +/// Interface for privacy settings repository operations +abstract class PrivacySettingsRepositoryInterface { + /// Fetch the current staff member's profile visibility setting + Future getProfileVisibility(); + + /// Update profile visibility preference + /// + /// Returns the updated profile visibility status + Future updateProfileVisibility(bool isVisible); + + /// Fetch terms of service content + Future getTermsOfService(); + + /// Fetch privacy policy content + Future getPrivacyPolicy(); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart new file mode 100644 index 00000000..5e255b7c --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_privacy_policy_usecase.dart @@ -0,0 +1,17 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve privacy policy +class GetPrivacyPolicyUseCase { + + GetPrivacyPolicyUseCase(this._repository); + final PrivacySettingsRepositoryInterface _repository; + + /// Execute the use case to get privacy policy + Future call() async { + try { + return await _repository.getPrivacyPolicy(); + } catch (e) { + return 'Privacy Policy is currently unavailable.'; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart new file mode 100644 index 00000000..6c278a3f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_profile_visibility_usecase.dart @@ -0,0 +1,19 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve the current staff member's profile visibility setting +class GetProfileVisibilityUseCase { + + GetProfileVisibilityUseCase(this._repository); + final PrivacySettingsRepositoryInterface _repository; + + /// Execute the use case to get profile visibility status + /// Returns true if profile is visible, false if hidden + Future call() async { + try { + return await _repository.getProfileVisibility(); + } catch (e) { + // Return default (visible) on error + return true; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart new file mode 100644 index 00000000..8b30cf57 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/get_terms_usecase.dart @@ -0,0 +1,17 @@ +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Use case to retrieve terms of service +class GetTermsUseCase { + + GetTermsUseCase(this._repository); + final PrivacySettingsRepositoryInterface _repository; + + /// Execute the use case to get terms of service + Future call() async { + try { + return await _repository.getTermsOfService(); + } catch (e) { + return 'Terms of Service is currently unavailable.'; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart new file mode 100644 index 00000000..91a17b7d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/domain/usecases/update_profile_visibility_usecase.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +import '../repositories/privacy_settings_repository_interface.dart'; + +/// Parameters for updating profile visibility +class UpdateProfileVisibilityParams extends Equatable { + + const UpdateProfileVisibilityParams({required this.isVisible}); + /// Whether to show (true) or hide (false) the profile + final bool isVisible; + + @override + List get props => [isVisible]; +} + +/// Use case to update profile visibility setting +class UpdateProfileVisibilityUseCase { + + UpdateProfileVisibilityUseCase(this._repository); + final PrivacySettingsRepositoryInterface _repository; + + /// Execute the use case to update profile visibility + /// Returns the updated visibility status + Future call(UpdateProfileVisibilityParams params) async { + try { + return await _repository.updateProfileVisibility(params.isVisible); + } catch (e) { + // Return the requested state on error (optimistic) + return params.isVisible; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart new file mode 100644 index 00000000..2bc7fcb4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/privacy_policy_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../domain/usecases/get_privacy_policy_usecase.dart'; + +/// State for Privacy Policy cubit +class PrivacyPolicyState { + + const PrivacyPolicyState({ + this.content, + this.isLoading = false, + this.error, + }); + final String? content; + final bool isLoading; + final String? error; + + PrivacyPolicyState copyWith({ + String? content, + bool? isLoading, + String? error, + }) { + return PrivacyPolicyState( + content: content ?? this.content, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +/// Cubit for managing Privacy Policy content +class PrivacyPolicyCubit extends Cubit { + + PrivacyPolicyCubit({ + required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase, + }) : _getPrivacyPolicyUseCase = getPrivacyPolicyUseCase, + super(const PrivacyPolicyState()); + final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase; + + /// Fetch privacy policy content + Future fetchPrivacyPolicy() async { + emit(state.copyWith(isLoading: true, error: null)); + try { + final String content = await _getPrivacyPolicyUseCase(); + emit(state.copyWith( + content: content, + isLoading: false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + error: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart new file mode 100644 index 00000000..2c1ab197 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/legal/terms_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../domain/usecases/get_terms_usecase.dart'; + +/// State for Terms of Service cubit +class TermsState { + + const TermsState({ + this.content, + this.isLoading = false, + this.error, + }); + final String? content; + final bool isLoading; + final String? error; + + TermsState copyWith({ + String? content, + bool? isLoading, + String? error, + }) { + return TermsState( + content: content ?? this.content, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +/// Cubit for managing Terms of Service content +class TermsCubit extends Cubit { + + TermsCubit({ + required GetTermsUseCase getTermsUseCase, + }) : _getTermsUseCase = getTermsUseCase, + super(const TermsState()); + final GetTermsUseCase _getTermsUseCase; + + /// Fetch terms of service content + Future fetchTerms() async { + emit(state.copyWith(isLoading: true, error: null)); + try { + final String content = await _getTermsUseCase(); + emit(state.copyWith( + content: content, + isLoading: false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + error: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart new file mode 100644 index 00000000..54c3bd3a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_bloc.dart @@ -0,0 +1,143 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; + +import '../../domain/usecases/get_profile_visibility_usecase.dart'; +import '../../domain/usecases/update_profile_visibility_usecase.dart'; +import '../../domain/usecases/get_terms_usecase.dart'; +import '../../domain/usecases/get_privacy_policy_usecase.dart'; + +part 'privacy_security_event.dart'; +part 'privacy_security_state.dart'; + +/// BLoC managing privacy and security settings state +class PrivacySecurityBloc + extends Bloc { + + PrivacySecurityBloc({ + required GetProfileVisibilityUseCase getProfileVisibilityUseCase, + required UpdateProfileVisibilityUseCase updateProfileVisibilityUseCase, + required GetTermsUseCase getTermsUseCase, + required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase, + }) : _getProfileVisibilityUseCase = getProfileVisibilityUseCase, + _updateProfileVisibilityUseCase = updateProfileVisibilityUseCase, + _getTermsUseCase = getTermsUseCase, + _getPrivacyPolicyUseCase = getPrivacyPolicyUseCase, + super(const PrivacySecurityState()) { + on(_onFetchProfileVisibility); + on(_onUpdateProfileVisibility); + on(_onFetchTerms); + on(_onFetchPrivacyPolicy); + on(_onClearProfileVisibilityUpdated); + } + final GetProfileVisibilityUseCase _getProfileVisibilityUseCase; + final UpdateProfileVisibilityUseCase _updateProfileVisibilityUseCase; + final GetTermsUseCase _getTermsUseCase; + final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase; + + Future _onFetchProfileVisibility( + FetchProfileVisibilityEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null)); + + try { + final bool isVisible = await _getProfileVisibilityUseCase.call(); + emit( + state.copyWith( + isLoading: false, + isProfileVisible: isVisible, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: 'Failed to fetch profile visibility', + ), + ); + } + } + + Future _onUpdateProfileVisibility( + UpdateProfileVisibilityEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isUpdating: true, error: null, profileVisibilityUpdated: false)); + + try { + final bool isVisible = await _updateProfileVisibilityUseCase.call( + UpdateProfileVisibilityParams(isVisible: event.isVisible), + ); + emit( + state.copyWith( + isUpdating: false, + isProfileVisible: isVisible, + profileVisibilityUpdated: true, + ), + ); + } catch (e) { + emit( + state.copyWith( + isUpdating: false, + error: 'Failed to update profile visibility', + profileVisibilityUpdated: false, + ), + ); + } + } + + Future _onFetchTerms( + FetchTermsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoadingTerms: true, error: null)); + + try { + final String content = await _getTermsUseCase.call(); + emit( + state.copyWith( + isLoadingTerms: false, + termsContent: content, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoadingTerms: false, + error: 'Failed to fetch terms of service', + ), + ); + } + } + + Future _onFetchPrivacyPolicy( + FetchPrivacyPolicyEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoadingPrivacyPolicy: true, error: null)); + + try { + final String content = await _getPrivacyPolicyUseCase.call(); + emit( + state.copyWith( + isLoadingPrivacyPolicy: false, + privacyPolicyContent: content, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoadingPrivacyPolicy: false, + error: 'Failed to fetch privacy policy', + ), + ); + } + } + + void _onClearProfileVisibilityUpdated( + ClearProfileVisibilityUpdatedEvent event, + Emitter emit, + ) { + emit(state.copyWith(profileVisibilityUpdated: false)); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart new file mode 100644 index 00000000..f9d56e95 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_event.dart @@ -0,0 +1,40 @@ +part of 'privacy_security_bloc.dart'; + +/// Base class for privacy security BLoC events +abstract class PrivacySecurityEvent extends Equatable { + const PrivacySecurityEvent(); + + @override + List get props => []; +} + +/// Event to fetch current profile visibility setting +class FetchProfileVisibilityEvent extends PrivacySecurityEvent { + const FetchProfileVisibilityEvent(); +} + +/// Event to update profile visibility +class UpdateProfileVisibilityEvent extends PrivacySecurityEvent { + + const UpdateProfileVisibilityEvent({required this.isVisible}); + /// Whether to show (true) or hide (false) the profile + final bool isVisible; + + @override + List get props => [isVisible]; +} + +/// Event to fetch terms of service +class FetchTermsEvent extends PrivacySecurityEvent { + const FetchTermsEvent(); +} + +/// Event to fetch privacy policy +class FetchPrivacyPolicyEvent extends PrivacySecurityEvent { + const FetchPrivacyPolicyEvent(); +} + +/// Event to clear the profile visibility updated flag after showing snackbar +class ClearProfileVisibilityUpdatedEvent extends PrivacySecurityEvent { + const ClearProfileVisibilityUpdatedEvent(); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart new file mode 100644 index 00000000..0ef87813 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/blocs/privacy_security_state.dart @@ -0,0 +1,83 @@ +part of 'privacy_security_bloc.dart'; + +/// State for privacy security BLoC +class PrivacySecurityState extends Equatable { + + const PrivacySecurityState({ + this.isProfileVisible = true, + this.isLoading = false, + this.isUpdating = false, + this.profileVisibilityUpdated = false, + this.termsContent, + this.isLoadingTerms = false, + this.privacyPolicyContent, + this.isLoadingPrivacyPolicy = false, + this.error, + }); + /// Current profile visibility setting (true = visible, false = hidden) + final bool isProfileVisible; + + /// Whether profile visibility is currently loading + final bool isLoading; + + /// Whether profile visibility is currently being updated + final bool isUpdating; + + /// Whether the profile visibility was just successfully updated + final bool profileVisibilityUpdated; + + /// Terms of service content + final String? termsContent; + + /// Whether terms are currently loading + final bool isLoadingTerms; + + /// Privacy policy content + final String? privacyPolicyContent; + + /// Whether privacy policy is currently loading + final bool isLoadingPrivacyPolicy; + + /// Error message, if any + final String? error; + + /// Create a copy with optional field overrides + PrivacySecurityState copyWith({ + bool? isProfileVisible, + bool? isLoading, + bool? isUpdating, + bool? profileVisibilityUpdated, + String? termsContent, + bool? isLoadingTerms, + String? privacyPolicyContent, + bool? isLoadingPrivacyPolicy, + String? error, + }) { + return PrivacySecurityState( + isProfileVisible: isProfileVisible ?? this.isProfileVisible, + isLoading: isLoading ?? this.isLoading, + isUpdating: isUpdating ?? this.isUpdating, + profileVisibilityUpdated: profileVisibilityUpdated ?? this.profileVisibilityUpdated, + termsContent: termsContent ?? this.termsContent, + isLoadingTerms: isLoadingTerms ?? this.isLoadingTerms, + privacyPolicyContent: privacyPolicyContent ?? this.privacyPolicyContent, + isLoadingPrivacyPolicy: + isLoadingPrivacyPolicy ?? this.isLoadingPrivacyPolicy, + error: error, + ); + } + + @override + List get props => [ + isProfileVisible, + isLoading, + isUpdating, + profileVisibilityUpdated, + termsContent, + isLoadingTerms, + privacyPolicyContent, + isLoadingPrivacyPolicy, + error, + ]; +} + diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart new file mode 100644 index 00000000..510eca63 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart @@ -0,0 +1,66 @@ +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 '../../blocs/legal/privacy_policy_cubit.dart'; + +/// Page displaying the Privacy Policy document +class PrivacyPolicyPage extends StatelessWidget { + const PrivacyPolicyPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.privacy_policy.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => Modular.get()..fetchPrivacyPolicy(), + child: BlocBuilder( + builder: (BuildContext context, PrivacyPolicyState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + 'Error loading Privacy Policy: ${state.error}', + textAlign: TextAlign.center, + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + state.content ?? 'No content available', + style: UiTypography.body2r.copyWith( + height: 1.6, + color: UiColors.textPrimary, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart new file mode 100644 index 00000000..8bd8daae --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart @@ -0,0 +1,66 @@ +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 '../../blocs/legal/terms_cubit.dart'; + +/// Page displaying the Terms of Service document +class TermsOfServicePage extends StatelessWidget { + const TermsOfServicePage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.terms_of_service.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider( + create: (BuildContext context) => Modular.get()..fetchTerms(), + child: BlocBuilder( + builder: (BuildContext context, TermsState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + 'Error loading Terms of Service: ${state.error}', + textAlign: TextAlign.center, + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Text( + state.content ?? 'No content available', + style: UiTypography.body2r.copyWith( + height: 1.6, + color: UiColors.textPrimary, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart new file mode 100644 index 00000000..28749dbe --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart @@ -0,0 +1,53 @@ +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 '../blocs/privacy_security_bloc.dart'; +import '../widgets/legal/legal_section_widget.dart'; +import '../widgets/privacy/privacy_section_widget.dart'; + +/// Page displaying privacy & security settings for staff +class PrivacySecurityPage extends StatelessWidget { + const PrivacySecurityPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: t.staff_privacy_security.title, + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: BlocProvider.value( + value: Modular.get() + ..add(const FetchProfileVisibilityEvent()), + child: BlocBuilder( + builder: (BuildContext context, PrivacySecurityState state) { + if (state.isLoading) { + return const UiLoadingPage(); + } + + return const SingleChildScrollView( + padding: EdgeInsets.all(UiConstants.space6), + child: Column( + spacing: UiConstants.space6, + children: [ + // Privacy Section + PrivacySectionWidget(), + + // Legal Section + LegalSectionWidget(), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart new file mode 100644 index 00000000..d50540a3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/legal/legal_section_widget.dart @@ -0,0 +1,70 @@ +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/privacy_security_bloc.dart'; +import '../settings_action_tile_widget.dart'; +import '../settings_divider_widget.dart'; +import '../settings_section_header_widget.dart'; + +/// Widget displaying legal documents (Terms of Service and Privacy Policy) +class LegalSectionWidget extends StatelessWidget { + const LegalSectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + spacing: UiConstants.space4, + + children: [ + // Legal Section Header + SettingsSectionHeader( + title: t.staff_privacy_security.legal_section, + icon: UiIcons.shield, + ), + + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + SettingsActionTile( + title: t.staff_privacy_security.terms_of_service.title, + onTap: () => _navigateToTerms(context), + ), + const SettingsDivider(), + SettingsActionTile( + title: t.staff_privacy_security.privacy_policy.title, + onTap: () => _navigateToPrivacyPolicy(context), + ), + ], + ), + ), + ], + ); + } + + /// Navigate to terms of service page + void _navigateToTerms(BuildContext context) { + BlocProvider.of(context).add(const FetchTermsEvent()); + + // Navigate using typed navigator + Modular.to.toTermsOfService(); + } + + /// Navigate to privacy policy page + void _navigateToPrivacyPolicy(BuildContext context) { + BlocProvider.of( + context, + ).add(const FetchPrivacyPolicyEvent()); + + // Navigate using typed navigator + Modular.to.toPrivacyPolicy(); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart new file mode 100644 index 00000000..c8a54a63 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart @@ -0,0 +1,70 @@ +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 '../../blocs/privacy_security_bloc.dart'; +import '../settings_section_header_widget.dart'; +import '../settings_switch_tile_widget.dart'; + +/// Widget displaying privacy settings including profile visibility preference +class PrivacySectionWidget extends StatelessWidget { + const PrivacySectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (BuildContext context, PrivacySecurityState state) { + // Show success message when profile visibility update just completed + if (state.profileVisibilityUpdated && state.error == null) { + UiSnackbar.show( + context, + message: t.staff_privacy_security.success.profile_visibility_updated, + type: UiSnackbarType.success, + ); + // Clear the flag after showing the snackbar + context.read().add( + const ClearProfileVisibilityUpdatedEvent(), + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, PrivacySecurityState state) { + return Column( + children: [ + // Privacy Section Header + SettingsSectionHeader( + title: t.staff_privacy_security.privacy_section, + icon: UiIcons.eye, + ), + const SizedBox(height: 12.0), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.border, + ), + ), + child: Column( + children: [ + SettingsSwitchTile( + title: t.staff_privacy_security.profile_visibility.title, + subtitle: t.staff_privacy_security.profile_visibility.subtitle, + value: state.isProfileVisible, + onChanged: (bool value) { + BlocProvider.of(context).add( + UpdateProfileVisibilityEvent(isVisible: value), + ); + }, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart new file mode 100644 index 00000000..a3f7122b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_action_tile_widget.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for action tile (tap to navigate) +class SettingsActionTile extends StatelessWidget { + + const SettingsActionTile({ + super.key, + required this.title, + this.subtitle, + required this.onTap, + }); + /// The title of the action + final String title; + + /// Optional subtitle describing the action + final String? subtitle; + + /// Callback when tile is tapped + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body2r.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: UiConstants.space1), + Text( + subtitle!, + style: UiTypography.footnote1r.copyWith( + color: UiColors.muted, + ), + ), + ], + ], + ), + ), + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart new file mode 100644 index 00000000..712312cf --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_divider_widget.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Divider widget for separating items within settings sections +class SettingsDivider extends StatelessWidget { + const SettingsDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Divider( + height: 1, + color: UiColors.border, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart new file mode 100644 index 00000000..84b2da58 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_section_header_widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for settings section header with icon +class SettingsSectionHeader extends StatelessWidget { + + const SettingsSectionHeader({ + super.key, + required this.title, + required this.icon, + }); + /// The title of the section + final String title; + + /// The icon to display next to the title + final IconData icon; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + title, + style: UiTypography.body1r.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart new file mode 100644 index 00000000..c8745e1f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/settings_switch_tile_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +/// Reusable widget for toggle tile in privacy settings +class SettingsSwitchTile extends StatelessWidget { + + const SettingsSwitchTile({ + super.key, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + }); + /// The title of the setting + final String title; + + /// The subtitle describing the setting + final String subtitle; + + /// Current toggle value + final bool value; + + /// Callback when toggle is changed + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: UiTypography.body2r), + Text(subtitle, style: UiTypography.footnote1r.textSecondary), + ], + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart new file mode 100644 index 00000000..22b0d405 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import 'data/repositories_impl/privacy_settings_repository_impl.dart'; +import 'domain/repositories/privacy_settings_repository_interface.dart'; +import 'domain/usecases/get_privacy_policy_usecase.dart'; +import 'domain/usecases/get_profile_visibility_usecase.dart'; +import 'domain/usecases/get_terms_usecase.dart'; +import 'domain/usecases/update_profile_visibility_usecase.dart'; +import 'presentation/blocs/legal/privacy_policy_cubit.dart'; +import 'presentation/blocs/legal/terms_cubit.dart'; +import 'presentation/blocs/privacy_security_bloc.dart'; +import 'presentation/pages/legal/privacy_policy_page.dart'; +import 'presentation/pages/legal/terms_of_service_page.dart'; +import 'presentation/pages/privacy_security_page.dart'; + +/// Module for privacy security feature +/// +/// Provides: +/// - Dependency injection for repositories, use cases, and BLoCs +/// - Route definitions delegated to core routing +class PrivacySecurityModule extends Module { + @override + void binds(Injector i) { + // Repository + i.addSingleton( + () => PrivacySettingsRepositoryImpl( + Modular.get(), + ), + ); + + // Use Cases + i.addSingleton( + () => GetProfileVisibilityUseCase( + i(), + ), + ); + i.addSingleton( + () => UpdateProfileVisibilityUseCase( + i(), + ), + ); + i.addSingleton( + () => GetTermsUseCase( + i(), + ), + ); + i.addSingleton( + () => GetPrivacyPolicyUseCase( + i(), + ), + ); + + // BLoC + i.add( + () => PrivacySecurityBloc( + getProfileVisibilityUseCase: i(), + updateProfileVisibilityUseCase: i(), + getTermsUseCase: i(), + getPrivacyPolicyUseCase: i(), + ), + ); + + // Legal Cubits + i.add( + () => TermsCubit( + getTermsUseCase: i(), + ), + ); + + i.add( + () => PrivacyPolicyCubit( + getPrivacyPolicyUseCase: i(), + ), + ); + } + + @override + void routes(RouteManager r) { + // Main privacy security page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.privacySecurity, + ), + child: (BuildContext context) => const PrivacySecurityPage(), + ); + + // Terms of Service page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.termsOfService, + ), + child: (BuildContext context) => const TermsOfServicePage(), + ); + + // Privacy Policy page + r.child( + StaffPaths.childRoute( + StaffPaths.privacySecurity, + StaffPaths.privacyPolicy, + ), + child: (BuildContext context) => const PrivacyPolicyPage(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart new file mode 100644 index 00000000..a638651d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/staff_privacy_security.dart @@ -0,0 +1,12 @@ +export 'src/domain/entities/privacy_settings_entity.dart'; +export 'src/domain/repositories/privacy_settings_repository_interface.dart'; +export 'src/domain/usecases/get_terms_usecase.dart'; +export 'src/domain/usecases/get_privacy_policy_usecase.dart'; +export 'src/data/repositories_impl/privacy_settings_repository_impl.dart'; +export 'src/presentation/blocs/privacy_security_bloc.dart'; +export 'src/presentation/pages/privacy_security_page.dart'; +export 'src/presentation/widgets/settings_switch_tile_widget.dart'; +export 'src/presentation/widgets/settings_action_tile_widget.dart'; +export 'src/presentation/widgets/settings_section_header_widget.dart'; +export 'src/presentation/widgets/settings_divider_widget.dart'; +export 'src/staff_privacy_security_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml new file mode 100644 index 00000000..d55e3e24 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml @@ -0,0 +1,42 @@ +name: staff_privacy_security +description: Privacy & Security settings feature for staff application. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + firebase_data_connect: ^0.2.2+1 + url_launcher: ^6.2.0 + + # Architecture Packages + krow_domain: + path: ../../../../../domain + krow_data_connect: + path: ../../../../../data_connect + krow_core: + path: ../../../../../core + design_system: + path: ../../../../../design_system + core_localization: + path: ../../../../../core_localization + + +dev_dependencies: + flutter_test: + sdk: flutter + bloc_test: ^9.1.0 + mocktail: ^1.0.0 + +flutter: + uses-material-design: true + assets: + - lib/src/assets/legal/ diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 9d799fcb..a41c5e1f 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -1,371 +1,70 @@ import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import 'package:intl/intl.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import '../../domain/repositories/shifts_repository_interface.dart'; -class ShiftsRepositoryImpl - implements ShiftsRepositoryInterface { +/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. +class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { + final dc.ShiftsConnectorRepository _connectorRepository; final dc.DataConnectService _service; - ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance; - - // Cache: ShiftID -> ApplicationID (For Accept/Decline) - final Map _shiftToAppIdMap = {}; - // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) - final Map _appToRoleIdMap = {}; + ShiftsRepositoryImpl({ + dc.ShiftsConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getShiftsRepository(), + _service = service ?? dc.DataConnectService.instance; @override Future> getMyShifts({ required DateTime start, required DateTime end, }) async { - return _fetchApplications(start: start, end: end); + final staffId = await _service.getStaffId(); + return _connectorRepository.getMyShifts( + staffId: staffId, + start: start, + end: end, + ); } @override Future> getPendingAssignments() async { - return []; + final staffId = await _service.getStaffId(); + return _connectorRepository.getPendingAssignments(staffId: staffId); } @override Future> getCancelledShifts() async { - return []; + final staffId = await _service.getStaffId(); + return _connectorRepository.getCancelledShifts(staffId: staffId); } @override Future> getHistoryShifts() async { final staffId = await _service.getStaffId(); - final fdc.QueryResult response = await _service.executeProtected(() => _service.connector - .listCompletedApplicationsByStaffId(staffId: staffId) - .execute()); - final List shifts = []; - - for (final app in response.data.applications) { - _shiftToAppIdMap[app.shift.id] = app.id; - _appToRoleIdMap[app.id] = app.shiftRole.id; - - final String roleName = app.shiftRole.role.name; - final String orderName = - (app.shift.order.eventName ?? '').trim().isNotEmpty - ? app.shift.order.eventName! - : app.shift.order.business.businessName; - final String title = '$roleName - $orderName'; - final DateTime? shiftDate = _service.toDateTime(app.shift.date); - final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _service.toDateTime(app.createdAt); - - shifts.add( - Shift( - id: app.shift.id, - roleId: app.shiftRole.roleId, - title: title, - clientName: app.shift.order.business.businessName, - logoUrl: app.shift.order.business.companyLogoUrl, - hourlyRate: app.shiftRole.role.costPerHour, - location: app.shift.location ?? '', - locationAddress: app.shift.order.teamHub.hubName, - date: shiftDate?.toIso8601String() ?? '', - startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: _mapStatus(dc.ApplicationStatus.CHECKED_OUT), - description: app.shift.description, - durationDays: app.shift.durationDays, - requiredSlots: app.shiftRole.count, - filledSlots: app.shiftRole.assigned ?? 0, - hasApplied: true, - latitude: app.shift.latitude, - longitude: app.shift.longitude, - breakInfo: BreakAdapter.fromData( - isPaid: app.shiftRole.isBreakPaid ?? false, - breakTime: app.shiftRole.breakType?.stringValue, - ), - ), - ); - } - return shifts; - } - - Future> _fetchApplications({ - DateTime? start, - DateTime? end, - }) async { - final staffId = await _service.getStaffId(); - var query = _service.connector.getApplicationsByStaffId(staffId: staffId); - if (start != null && end != null) { - query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end)); - } - final fdc.QueryResult response = await _service.executeProtected(() => query.execute()); - - final apps = response.data.applications; - final List shifts = []; - - for (final app in apps) { - _shiftToAppIdMap[app.shift.id] = app.id; - _appToRoleIdMap[app.id] = app.shiftRole.id; - - final String roleName = app.shiftRole.role.name; - final String orderName = - (app.shift.order.eventName ?? '').trim().isNotEmpty - ? app.shift.order.eventName! - : app.shift.order.business.businessName; - final String title = '$roleName - $orderName'; - final DateTime? shiftDate = _service.toDateTime(app.shift.date); - final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _service.toDateTime(app.createdAt); - - // Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED) - final bool hasCheckIn = app.checkInTime != null; - final bool hasCheckOut = app.checkOutTime != null; - dc.ApplicationStatus? appStatus; - if (app.status is dc.Known) { - appStatus = (app.status as dc.Known).value; - } - final String mappedStatus = hasCheckOut - ? 'completed' - : hasCheckIn - ? 'checked_in' - : _mapStatus(appStatus ?? dc.ApplicationStatus.CONFIRMED); - shifts.add( - Shift( - id: app.shift.id, - roleId: app.shiftRole.roleId, - title: title, - clientName: app.shift.order.business.businessName, - logoUrl: app.shift.order.business.companyLogoUrl, - hourlyRate: app.shiftRole.role.costPerHour, - location: app.shift.location ?? '', - locationAddress: app.shift.order.teamHub.hubName, - date: shiftDate?.toIso8601String() ?? '', - startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: mappedStatus, - description: app.shift.description, - durationDays: app.shift.durationDays, - requiredSlots: app.shiftRole.count, - filledSlots: app.shiftRole.assigned ?? 0, - hasApplied: true, - latitude: app.shift.latitude, - longitude: app.shift.longitude, - breakInfo: BreakAdapter.fromData( - isPaid: app.shiftRole.isBreakPaid ?? false, - breakTime: app.shiftRole.breakType?.stringValue, - ), - ), - ); - } - return shifts; - } - - String _mapStatus(dc.ApplicationStatus status) { - switch (status) { - case dc.ApplicationStatus.CONFIRMED: - return 'confirmed'; - case dc.ApplicationStatus.PENDING: - return 'pending'; - case dc.ApplicationStatus.CHECKED_OUT: - return 'completed'; - case dc.ApplicationStatus.REJECTED: - return 'cancelled'; - default: - return 'open'; - } + return _connectorRepository.getHistoryShifts(staffId: staffId); } @override Future> getAvailableShifts(String query, String type) async { - final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId; - if (vendorId == null || vendorId.isEmpty) { - return []; - } - - final fdc.QueryResult result = await _service.executeProtected(() => _service.connector - .listShiftRolesByVendorId(vendorId: vendorId) - .execute()); - - - - final allShiftRoles = result.data.shiftRoles; - - // Fetch my applications to filter out already booked shifts - final List myShifts = await _fetchApplications(); - final Set myShiftIds = myShifts.map((s) => s.id).toSet(); - - final List mappedShifts = []; - for (final sr in allShiftRoles) { - // Skip if I have already applied/booked this shift - if (myShiftIds.contains(sr.shiftId)) continue; - - - final DateTime? shiftDate = _service.toDateTime(sr.shift.date); - final startDt = _service.toDateTime(sr.startTime); - final endDt = _service.toDateTime(sr.endTime); - final createdDt = _service.toDateTime(sr.createdAt); - - mappedShifts.add( - Shift( - id: sr.shiftId, - roleId: sr.roleId, - title: sr.role.name, - clientName: sr.shift.order.business.businessName, - logoUrl: null, - hourlyRate: sr.role.costPerHour, - location: sr.shift.location ?? '', - locationAddress: sr.shift.locationAddress ?? '', - date: shiftDate?.toIso8601String() ?? '', - startTime: startDt != null - ? DateFormat('HH:mm').format(startDt) - : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: sr.shift.status?.stringValue.toLowerCase() ?? 'open', - description: sr.shift.description, - durationDays: sr.shift.durationDays, - requiredSlots: sr.count, - filledSlots: sr.assigned ?? 0, - latitude: sr.shift.latitude, - longitude: sr.shift.longitude, - breakInfo: BreakAdapter.fromData( - isPaid: sr.isBreakPaid ?? false, - breakTime: sr.breakType?.stringValue, - ), - ), - ); - } - - if (query.isNotEmpty) { - return mappedShifts - .where( - (s) => - s.title.toLowerCase().contains(query.toLowerCase()) || - s.clientName.toLowerCase().contains(query.toLowerCase()), - ) - .toList(); - } - - return mappedShifts; + final staffId = await _service.getStaffId(); + return _connectorRepository.getAvailableShifts( + staffId: staffId, + query: query, + type: type, + ); } @override Future getShiftDetails(String shiftId, {String? roleId}) async { - return _getShiftDetails(shiftId, roleId: roleId); - } - - Future _getShiftDetails(String shiftId, {String? roleId}) async { - if (roleId != null && roleId.isNotEmpty) { - final roleResult = await _service.executeProtected(() => _service.connector - .getShiftRoleById(shiftId: shiftId, roleId: roleId) - .execute()); - final sr = roleResult.data.shiftRole; - if (sr == null) return null; - - final DateTime? startDt = _service.toDateTime(sr.startTime); - final DateTime? endDt = _service.toDateTime(sr.endTime); - final DateTime? createdDt = _service.toDateTime(sr.createdAt); - - final String staffId = await _service.getStaffId(); - bool hasApplied = false; - String status = 'open'; - final apps = await _service.executeProtected(() => - _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); - final app = apps.data.applications - .where( - (a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId, - ) - .firstOrNull; - if (app != null) { - hasApplied = true; - if (app.status is dc.Known) { - final dc.ApplicationStatus s = - (app.status as dc.Known).value; - status = _mapStatus(s); - } - } - - return Shift( - id: sr.shiftId, - roleId: sr.roleId, - title: sr.shift.order.business.businessName, - clientName: sr.shift.order.business.businessName, - logoUrl: sr.shift.order.business.companyLogoUrl, - hourlyRate: sr.role.costPerHour, - location: sr.shift.location ?? sr.shift.order.teamHub.hubName, - locationAddress: sr.shift.locationAddress ?? '', - date: startDt?.toIso8601String() ?? '', - startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: status, - description: sr.shift.description, - durationDays: null, - requiredSlots: sr.count, - filledSlots: sr.assigned ?? 0, - hasApplied: hasApplied, - totalValue: sr.totalValue, - latitude: sr.shift.latitude, - longitude: sr.shift.longitude, - breakInfo: BreakAdapter.fromData( - isPaid: sr.isBreakPaid ?? false, - breakTime: sr.breakType?.stringValue, - ), - ); - } - - final fdc.QueryResult result = - await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); - final s = result.data.shift; - if (s == null) return null; - - int? required; - int? filled; - Break? breakInfo; - try { - final rolesRes = await _service.executeProtected(() => - _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); - if (rolesRes.data.shiftRoles.isNotEmpty) { - required = 0; - filled = 0; - for (var r in rolesRes.data.shiftRoles) { - required = (required ?? 0) + r.count; - filled = (filled ?? 0) + (r.assigned ?? 0); - } - // Use the first role's break info as a representative - final firstRole = rolesRes.data.shiftRoles.first; - breakInfo = BreakAdapter.fromData( - isPaid: firstRole.isBreakPaid ?? false, - breakTime: firstRole.breakType?.stringValue, - ); - } - } catch (_) {} - - final startDt = _service.toDateTime(s.startTime); - final endDt = _service.toDateTime(s.endTime); - final createdDt = _service.toDateTime(s.createdAt); - - return Shift( - id: s.id, - title: s.title, - clientName: s.order.business.businessName, - logoUrl: null, - hourlyRate: s.cost ?? 0.0, - location: s.location ?? '', - locationAddress: s.locationAddress ?? '', - date: startDt?.toIso8601String() ?? '', - startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: s.status?.stringValue ?? 'OPEN', - description: s.description, - durationDays: s.durationDays, - requiredSlots: required, - filledSlots: filled, - latitude: s.latitude, - longitude: s.longitude, - breakInfo: breakInfo, + final staffId = await _service.getStaffId(); + return _connectorRepository.getShiftDetails( + shiftId: shiftId, + staffId: staffId, + roleId: roleId, ); } @@ -376,182 +75,29 @@ class ShiftsRepositoryImpl String? roleId, }) async { final staffId = await _service.getStaffId(); - - String targetRoleId = roleId ?? ''; - if (targetRoleId.isEmpty) { - throw Exception('Missing role id.'); - } - - final roleResult = await _service.executeProtected(() => _service.connector - .getShiftRoleById(shiftId: shiftId, roleId: targetRoleId) - .execute()); - final role = roleResult.data.shiftRole; - if (role == null) { - throw Exception('Shift role not found'); - } - final shiftResult = - await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); - final shift = shiftResult.data.shift; - if (shift == null) { - throw Exception('Shift not found'); - } - final DateTime? shiftDate = _service.toDateTime(shift.date); - if (shiftDate != null) { - final DateTime dayStartUtc = DateTime.utc( - shiftDate.year, - shiftDate.month, - shiftDate.day, - ); - final DateTime dayEndUtc = DateTime.utc( - shiftDate.year, - shiftDate.month, - shiftDate.day, - 23, - 59, - 59, - 999, - 999, - ); - - final dayApplications = await _service.executeProtected(() => _service.connector - .vaidateDayStaffApplication(staffId: staffId) - .dayStart(_service.toTimestamp(dayStartUtc)) - .dayEnd(_service.toTimestamp(dayEndUtc)) - .execute()); - if (dayApplications.data.applications.isNotEmpty) { - throw Exception('The user already has a shift that day.'); - } - } - final existingApplicationResult = await _service.executeProtected(() => _service.connector - .getApplicationByStaffShiftAndRole( - staffId: staffId, - shiftId: shiftId, - roleId: targetRoleId, - ) - .execute()); - if (existingApplicationResult.data.applications.isNotEmpty) { - throw Exception('Application already exists.'); - } - final int assigned = role.assigned ?? 0; - if (assigned >= role.count) { - throw Exception('This shift is full.'); - } - - final int filled = shift.filled ?? 0; - - String? appId; - bool updatedRole = false; - bool updatedShift = false; - try { - final appResult = await _service.executeProtected(() => _service.connector - .createApplication( - shiftId: shiftId, - staffId: staffId, - roleId: targetRoleId, - status: dc.ApplicationStatus.CONFIRMED, - origin: dc.ApplicationOrigin.STAFF, - ) - // TODO: this should be PENDING so a vendor can accept it. - .execute()); - appId = appResult.data.application_insert.id; - - await _service.executeProtected(() => _service.connector - .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) - .assigned(assigned + 1) - .execute()); - updatedRole = true; - - await _service.executeProtected( - () => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute()); - updatedShift = true; - } catch (e) { - if (updatedShift) { - try { - await _service.connector.updateShift(id: shiftId).filled(filled).execute(); - } catch (_) {} - } - if (updatedRole) { - try { - await _service.connector - .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) - .assigned(assigned) - .execute(); - } catch (_) {} - } - if (appId != null) { - try { - await _service.connector.deleteApplication(id: appId).execute(); - } catch (_) {} - } - rethrow; - } + return _connectorRepository.applyForShift( + shiftId: shiftId, + staffId: staffId, + isInstantBook: isInstantBook, + roleId: roleId, + ); } @override Future acceptShift(String shiftId) async { - await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED); + final staffId = await _service.getStaffId(); + return _connectorRepository.acceptShift( + shiftId: shiftId, + staffId: staffId, + ); } @override Future declineShift(String shiftId) async { - await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED); - } - - Future _updateApplicationStatus( - String shiftId, - dc.ApplicationStatus newStatus, - ) async { - String? appId = _shiftToAppIdMap[shiftId]; - String? roleId; - - if (appId == null) { - // Try to find it in pending - await getPendingAssignments(); - } - // Re-check map - appId = _shiftToAppIdMap[shiftId]; - if (appId != null) { - roleId = _appToRoleIdMap[appId]; - } else { - // Fallback fetch - final staffId = await _service.getStaffId(); - final apps = await _service.executeProtected(() => - _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); - final app = apps.data.applications - .where((a) => a.shiftId == shiftId) - .firstOrNull; - if (app != null) { - appId = app.id; - roleId = app.shiftRole.id; - } - } - - if (appId == null || roleId == null) { - // If we are rejecting and can't find an application, create one as rejected (declining an available shift) - if (newStatus == dc.ApplicationStatus.REJECTED) { - final rolesResult = await _service.executeProtected(() => - _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); - if (rolesResult.data.shiftRoles.isNotEmpty) { - final role = rolesResult.data.shiftRoles.first; - final staffId = await _service.getStaffId(); - await _service.executeProtected(() => _service.connector - .createApplication( - shiftId: shiftId, - staffId: staffId, - roleId: role.id, - status: dc.ApplicationStatus.REJECTED, - origin: dc.ApplicationOrigin.STAFF, - ) - .execute()); - return; - } - } - throw Exception("Application not found for shift $shiftId"); - } - - await _service.executeProtected(() => _service.connector - .updateApplicationStatus(id: appId!) - .status(newStatus) - .execute()); + final staffId = await _service.getStaffId(); + return _connectorRepository.declineShift( + shiftId: shiftId, + staffId: staffId, + ); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 5e1f386d..5d46c536 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -29,7 +29,7 @@ class ShiftDetailsBloc extends Bloc ) async { emit(ShiftDetailsLoading()); await handleError( - emit: emit, + emit: emit.call, action: () async { final shift = await getShiftDetails( GetShiftDetailsArguments(shiftId: event.shiftId, roleId: event.roleId), @@ -49,7 +49,7 @@ class ShiftDetailsBloc extends Bloc Emitter emit, ) async { await handleError( - emit: emit, + emit: emit.call, action: () async { await applyForShift( event.shiftId, @@ -69,7 +69,7 @@ class ShiftDetailsBloc extends Bloc Emitter emit, ) async { await handleError( - emit: emit, + emit: emit.call, action: () async { await declineShift(event.shiftId); emit(const ShiftActionSuccess("Shift declined")); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 6a8c1c43..2d2a9f8c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:meta/meta.dart'; @@ -22,6 +23,7 @@ class ShiftsBloc extends Bloc final GetPendingAssignmentsUseCase getPendingAssignments; final GetCancelledShiftsUseCase getCancelledShifts; final GetHistoryShiftsUseCase getHistoryShifts; + final GetProfileCompletionUseCase getProfileCompletion; ShiftsBloc({ required this.getMyShifts, @@ -29,47 +31,53 @@ class ShiftsBloc extends Bloc required this.getPendingAssignments, required this.getCancelledShifts, required this.getHistoryShifts, - }) : super(ShiftsInitial()) { + required this.getProfileCompletion, + }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); on(_onLoadAvailableShifts); on(_onLoadFindFirst); on(_onLoadShiftsForRange); on(_onFilterAvailableShifts); + on(_onCheckProfileCompletion); } Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, ) async { - if (state is! ShiftsLoaded) { - emit(ShiftsLoading()); + if (state.status != ShiftsStatus.loaded) { + emit(state.copyWith(status: ShiftsStatus.loading)); } await handleError( - emit: emit, + emit: emit.call, action: () async { final List days = _getCalendarDaysForOffset(0); final myShiftsResult = await getMyShifts( GetMyShiftsArguments(start: days.first, end: days.last), ); - emit(ShiftsLoaded( - myShifts: myShiftsResult, - pendingShifts: const [], - cancelledShifts: const [], - availableShifts: const [], - historyShifts: const [], - availableLoading: false, - availableLoaded: false, - historyLoading: false, - historyLoaded: false, - myShiftsLoaded: true, - searchQuery: '', - jobType: 'all', - )); + emit( + state.copyWith( + status: ShiftsStatus.loaded, + myShifts: myShiftsResult, + pendingShifts: const [], + cancelledShifts: const [], + availableShifts: const [], + historyShifts: const [], + availableLoading: false, + availableLoaded: false, + historyLoading: false, + historyLoaded: false, + myShiftsLoaded: true, + searchQuery: '', + jobType: 'all', + ), + ); }, - onError: (String errorKey) => ShiftsError(errorKey), + onError: (String errorKey) => + state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey), ); } @@ -77,27 +85,29 @@ class ShiftsBloc extends Bloc LoadHistoryShiftsEvent event, Emitter emit, ) async { - final currentState = state; - if (currentState is! ShiftsLoaded) return; - if (currentState.historyLoading || currentState.historyLoaded) return; + if (state.status != ShiftsStatus.loaded) return; + if (state.historyLoading || state.historyLoaded) return; - emit(currentState.copyWith(historyLoading: true)); + emit(state.copyWith(historyLoading: true)); await handleError( - emit: emit, + emit: emit.call, action: () async { final historyResult = await getHistoryShifts(); - emit(currentState.copyWith( - myShiftsLoaded: true, - historyShifts: historyResult, - historyLoading: false, - historyLoaded: true, - )); + emit( + state.copyWith( + myShiftsLoaded: true, + historyShifts: historyResult, + historyLoading: false, + historyLoaded: true, + ), + ); }, onError: (String errorKey) { - if (state is ShiftsLoaded) { - return (state as ShiftsLoaded).copyWith(historyLoading: false); - } - return ShiftsError(errorKey); + return state.copyWith( + historyLoading: false, + status: ShiftsStatus.error, + errorMessage: errorKey, + ); }, ); } @@ -106,27 +116,32 @@ class ShiftsBloc extends Bloc LoadAvailableShiftsEvent event, Emitter emit, ) async { - final currentState = state; - if (currentState is! ShiftsLoaded) return; - if (currentState.availableLoading || currentState.availableLoaded) return; + if (state.status != ShiftsStatus.loaded) return; + if (!event.force && (state.availableLoading || state.availableLoaded)) { + return; + } - emit(currentState.copyWith(availableLoading: true)); + emit(state.copyWith(availableLoading: true, availableLoaded: false)); await handleError( - emit: emit, + emit: emit.call, action: () async { - final availableResult = - await getAvailableShifts(const GetAvailableShiftsArguments()); - emit(currentState.copyWith( - availableShifts: _filterPastShifts(availableResult), - availableLoading: false, - availableLoaded: true, - )); + final availableResult = await getAvailableShifts( + const GetAvailableShiftsArguments(), + ); + emit( + state.copyWith( + availableShifts: _filterPastShifts(availableResult), + availableLoading: false, + availableLoaded: true, + ), + ); }, onError: (String errorKey) { - if (state is ShiftsLoaded) { - return (state as ShiftsLoaded).copyWith(availableLoading: false); - } - return ShiftsError(errorKey); + return state.copyWith( + availableLoading: false, + status: ShiftsStatus.error, + errorMessage: errorKey, + ); }, ); } @@ -135,62 +150,51 @@ class ShiftsBloc extends Bloc LoadFindFirstEvent event, Emitter emit, ) async { - if (state is! ShiftsLoaded) { - emit(const ShiftsLoaded( - myShifts: [], - pendingShifts: [], - cancelledShifts: [], - availableShifts: [], - historyShifts: [], - availableLoading: false, - availableLoaded: false, - historyLoading: false, - historyLoaded: false, - myShiftsLoaded: false, - searchQuery: '', - jobType: 'all', - )); + if (state.status != ShiftsStatus.loaded) { + emit( + state.copyWith( + status: ShiftsStatus.loading, + myShifts: const [], + pendingShifts: const [], + cancelledShifts: const [], + availableShifts: const [], + historyShifts: const [], + availableLoading: false, + availableLoaded: false, + historyLoading: false, + historyLoaded: false, + myShiftsLoaded: false, + searchQuery: '', + jobType: 'all', + ), + ); } - final currentState = state is ShiftsLoaded ? state as ShiftsLoaded : null; - if (currentState != null && currentState.availableLoaded) return; + if (state.availableLoaded) return; - if (currentState != null) { - emit(currentState.copyWith(availableLoading: true)); - } + emit(state.copyWith(availableLoading: true)); await handleError( - emit: emit, + emit: emit.call, action: () async { - final availableResult = - await getAvailableShifts(const GetAvailableShiftsArguments()); - final loadedState = state is ShiftsLoaded - ? state as ShiftsLoaded - : const ShiftsLoaded( - myShifts: [], - pendingShifts: [], - cancelledShifts: [], - availableShifts: [], - historyShifts: [], - availableLoading: true, - availableLoaded: false, - historyLoading: false, - historyLoaded: false, - myShiftsLoaded: false, - searchQuery: '', - jobType: 'all', - ); - emit(loadedState.copyWith( - availableShifts: _filterPastShifts(availableResult), - availableLoading: false, - availableLoaded: true, - )); + final availableResult = await getAvailableShifts( + const GetAvailableShiftsArguments(), + ); + emit( + state.copyWith( + status: ShiftsStatus.loaded, + availableShifts: _filterPastShifts(availableResult), + availableLoading: false, + availableLoaded: true, + ), + ); }, onError: (String errorKey) { - if (state is ShiftsLoaded) { - return (state as ShiftsLoaded).copyWith(availableLoading: false); - } - return ShiftsError(errorKey); + return state.copyWith( + availableLoading: false, + status: ShiftsStatus.error, + errorMessage: errorKey, + ); }, ); } @@ -200,37 +204,22 @@ class ShiftsBloc extends Bloc Emitter emit, ) async { await handleError( - emit: emit, + emit: emit.call, action: () async { final myShiftsResult = await getMyShifts( GetMyShiftsArguments(start: event.start, end: event.end), ); - if (state is ShiftsLoaded) { - final currentState = state as ShiftsLoaded; - emit(currentState.copyWith( + emit( + state.copyWith( + status: ShiftsStatus.loaded, myShifts: myShiftsResult, myShiftsLoaded: true, - )); - return; - } - - emit(ShiftsLoaded( - myShifts: myShiftsResult, - pendingShifts: const [], - cancelledShifts: const [], - availableShifts: const [], - historyShifts: const [], - availableLoading: false, - availableLoaded: false, - historyLoading: false, - historyLoaded: false, - myShiftsLoaded: true, - searchQuery: '', - jobType: 'all', - )); + ), + ); }, - onError: (String errorKey) => ShiftsError(errorKey), + onError: (String errorKey) => + state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey), ); } @@ -238,36 +227,56 @@ class ShiftsBloc extends Bloc FilterAvailableShiftsEvent event, Emitter emit, ) async { - final currentState = state; - if (currentState is ShiftsLoaded) { - if (!currentState.availableLoaded && !currentState.availableLoading) { + if (state.status == ShiftsStatus.loaded) { + if (!state.availableLoaded && !state.availableLoading) { add(LoadAvailableShiftsEvent()); return; } await handleError( - emit: emit, + emit: emit.call, action: () async { - final result = await getAvailableShifts(GetAvailableShiftsArguments( - query: event.query ?? currentState.searchQuery, - type: event.jobType ?? currentState.jobType, - )); + final result = await getAvailableShifts( + GetAvailableShiftsArguments( + query: event.query ?? state.searchQuery, + type: event.jobType ?? state.jobType, + ), + ); - emit(currentState.copyWith( - availableShifts: _filterPastShifts(result), - searchQuery: event.query ?? currentState.searchQuery, - jobType: event.jobType ?? currentState.jobType, - )); + emit( + state.copyWith( + availableShifts: _filterPastShifts(result), + searchQuery: event.query ?? state.searchQuery, + jobType: event.jobType ?? state.jobType, + ), + ); }, onError: (String errorKey) { - // Stay on current state for filtering errors, maybe show a snackbar? - // For now just logging is enough via handleError mixin. - return currentState; + return state.copyWith( + status: ShiftsStatus.error, + errorMessage: errorKey, + ); }, ); } } + Future _onCheckProfileCompletion( + CheckProfileCompletionEvent event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + final bool isComplete = await getProfileCompletion(); + emit(state.copyWith(profileComplete: isComplete)); + }, + onError: (String errorKey) { + return state.copyWith(profileComplete: false); + }, + ); + } + List _getCalendarDaysForOffset(int weekOffset) { final now = DateTime.now(); final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index d25866e0..7e1632d2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -12,7 +12,13 @@ class LoadShiftsEvent extends ShiftsEvent {} class LoadHistoryShiftsEvent extends ShiftsEvent {} -class LoadAvailableShiftsEvent extends ShiftsEvent {} +class LoadAvailableShiftsEvent extends ShiftsEvent { + final bool force; + const LoadAvailableShiftsEvent({this.force = false}); + + @override + List get props => [force]; +} class LoadFindFirstEvent extends ShiftsEvent {} @@ -54,3 +60,10 @@ class DeclineShiftEvent extends ShiftsEvent { @override List get props => [shiftId]; } + +class CheckProfileCompletionEvent extends ShiftsEvent { + const CheckProfileCompletionEvent(); + + @override + List get props => []; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index d32e3fba..f9e108d5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -1,18 +1,9 @@ part of 'shifts_bloc.dart'; -@immutable -sealed class ShiftsState extends Equatable { - const ShiftsState(); - - @override - List get props => []; -} +enum ShiftsStatus { initial, loading, loaded, error } -class ShiftsInitial extends ShiftsState {} - -class ShiftsLoading extends ShiftsState {} - -class ShiftsLoaded extends ShiftsState { +class ShiftsState extends Equatable { + final ShiftsStatus status; final List myShifts; final List pendingShifts; final List cancelledShifts; @@ -25,23 +16,29 @@ class ShiftsLoaded extends ShiftsState { final bool myShiftsLoaded; final String searchQuery; final String jobType; + final bool? profileComplete; + final String? errorMessage; - const ShiftsLoaded({ - required this.myShifts, - required this.pendingShifts, - required this.cancelledShifts, - required this.availableShifts, - required this.historyShifts, - required this.availableLoading, - required this.availableLoaded, - required this.historyLoading, - required this.historyLoaded, - required this.myShiftsLoaded, - required this.searchQuery, - required this.jobType, + const ShiftsState({ + this.status = ShiftsStatus.initial, + this.myShifts = const [], + this.pendingShifts = const [], + this.cancelledShifts = const [], + this.availableShifts = const [], + this.historyShifts = const [], + this.availableLoading = false, + this.availableLoaded = false, + this.historyLoading = false, + this.historyLoaded = false, + this.myShiftsLoaded = false, + this.searchQuery = '', + this.jobType = 'all', + this.profileComplete, + this.errorMessage, }); - ShiftsLoaded copyWith({ + ShiftsState copyWith({ + ShiftsStatus? status, List? myShifts, List? pendingShifts, List? cancelledShifts, @@ -54,8 +51,11 @@ class ShiftsLoaded extends ShiftsState { bool? myShiftsLoaded, String? searchQuery, String? jobType, + bool? profileComplete, + String? errorMessage, }) { - return ShiftsLoaded( + return ShiftsState( + status: status ?? this.status, myShifts: myShifts ?? this.myShifts, pendingShifts: pendingShifts ?? this.pendingShifts, cancelledShifts: cancelledShifts ?? this.cancelledShifts, @@ -68,31 +68,27 @@ class ShiftsLoaded extends ShiftsState { myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded, searchQuery: searchQuery ?? this.searchQuery, jobType: jobType ?? this.jobType, + profileComplete: profileComplete ?? this.profileComplete, + errorMessage: errorMessage ?? this.errorMessage, ); } @override - List get props => [ - myShifts, - pendingShifts, - cancelledShifts, - availableShifts, - historyShifts, - availableLoading, - availableLoaded, - historyLoading, - historyLoaded, - myShiftsLoaded, - searchQuery, - jobType, - ]; -} - -class ShiftsError extends ShiftsState { - final String message; - - const ShiftsError(this.message); - - @override - List get props => [message]; + List get props => [ + status, + myShifts, + pendingShifts, + cancelledShifts, + availableShifts, + historyShifts, + availableLoading, + availableLoaded, + historyLoading, + historyLoaded, + myShiftsLoaded, + searchQuery, + jobType, + profileComplete, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index e4563de1..7500eca6 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -16,6 +16,7 @@ import '../widgets/shift_details/shift_description_section.dart'; import '../widgets/shift_details/shift_details_bottom_bar.dart'; import '../widgets/shift_details/shift_details_header.dart'; import '../widgets/shift_details/shift_location_section.dart'; +import '../widgets/shift_details/shift_schedule_summary_section.dart'; import '../widgets/shift_details/shift_stats_row.dart'; class ShiftDetailsPage extends StatefulWidget { @@ -93,14 +94,24 @@ class _ShiftDetailsPageState extends State { message: state.message, type: UiSnackbarType.success, ); - Modular.to.toShifts(selectedDate: state.shiftDate); + Modular.to.toShifts( + selectedDate: state.shiftDate, + initialTab: 'myshifts', + refreshAvailable: true, + ); } else if (state is ShiftDetailsError) { if (_isApplying) { - UiSnackbar.show( - context, - message: translateErrorKey(state.message), - type: UiSnackbarType.error, - ); + final String errorMessage = state.message.toUpperCase(); + if (errorMessage.contains('ELIGIBILITY') || + errorMessage.contains('COMPLIANCE')) { + _showEligibilityErrorDialog(context); + } else { + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, + ); + } } _isApplying = false; } @@ -112,7 +123,7 @@ class _ShiftDetailsPageState extends State { ); } - Shift displayShift = widget.shift; + final Shift displayShift = widget.shift; final i18n = Translations.of(context).staff_shifts.shift_details; final duration = _calculateDuration(displayShift); @@ -144,6 +155,7 @@ class _ShiftDetailsPageState extends State { const Divider(height: 1, thickness: 0.5), ShiftDateTimeSection( date: displayShift.date, + endDate: displayShift.endDate, startTime: displayShift.startTime, endTime: displayShift.endTime, shiftDateLabel: i18n.shift_date, @@ -151,6 +163,8 @@ class _ShiftDetailsPageState extends State { clockOutLabel: i18n.end_time, ), const Divider(height: 1, thickness: 0.5), + ShiftScheduleSummarySection(shift: displayShift), + const Divider(height: 1, thickness: 0.5), if (displayShift.breakInfo != null && displayShift.breakInfo!.duration != BreakDuration.none) ...[ @@ -292,4 +306,38 @@ class _ShiftDetailsPageState extends State { Navigator.of(context, rootNavigator: true).pop(); _actionDialogOpen = false; } + + void _showEligibilityErrorDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + backgroundColor: UiColors.bgPopup, + shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), + title: Row( + children: [ + const Icon(UiIcons.warning, color: UiColors.error), + const SizedBox(width: UiConstants.space2), + Expanded(child: Text("Eligibility Requirements")), + ], + ), + content: Text( + "You are missing required certifications or documents to claim this shift. Please upload them to continue.", + style: UiTypography.body2r.textSecondary, + ), + actions: [ + UiButton.secondary( + text: "Cancel", + onPressed: () => Navigator.of(ctx).pop(), + ), + UiButton.primary( + text: "Go to Certificates", + onPressed: () { + Navigator.of(ctx).pop(); + Modular.to.pushNamed(StaffPaths.certificates); + }, + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 1b6e1592..b515c21f 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -5,44 +5,58 @@ import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/shifts/shifts_bloc.dart'; +import '../utils/shift_tab_type.dart'; import '../widgets/tabs/my_shifts_tab.dart'; import '../widgets/tabs/find_shifts_tab.dart'; import '../widgets/tabs/history_shifts_tab.dart'; class ShiftsPage extends StatefulWidget { - final String? initialTab; + final ShiftTabType? initialTab; final DateTime? selectedDate; - const ShiftsPage({super.key, this.initialTab, this.selectedDate}); + final bool refreshAvailable; + const ShiftsPage({ + super.key, + this.initialTab, + this.selectedDate, + this.refreshAvailable = false, + }); @override State createState() => _ShiftsPageState(); } class _ShiftsPageState extends State { - late String _activeTab; + late ShiftTabType _activeTab; DateTime? _selectedDate; bool _prioritizeFind = false; + bool _refreshAvailable = false; + bool _pendingAvailableRefresh = false; final ShiftsBloc _bloc = Modular.get(); @override void initState() { super.initState(); - _activeTab = widget.initialTab ?? 'myshifts'; + _activeTab = widget.initialTab ?? ShiftTabType.find; _selectedDate = widget.selectedDate; - _prioritizeFind = widget.initialTab == 'find'; + _prioritizeFind = _activeTab == ShiftTabType.find; + _refreshAvailable = widget.refreshAvailable; + _pendingAvailableRefresh = widget.refreshAvailable; if (_prioritizeFind) { _bloc.add(LoadFindFirstEvent()); } else { _bloc.add(LoadShiftsEvent()); } - if (_activeTab == 'history') { + if (_activeTab == ShiftTabType.history) { _bloc.add(LoadHistoryShiftsEvent()); } - if (_activeTab == 'find') { + if (_activeTab == ShiftTabType.find) { if (!_prioritizeFind) { - _bloc.add(LoadAvailableShiftsEvent()); + _bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable)); } } + + // Check profile completion + _bloc.add(const CheckProfileCompletionEvent()); } @override @@ -51,7 +65,7 @@ class _ShiftsPageState extends State { if (widget.initialTab != null && widget.initialTab != _activeTab) { setState(() { _activeTab = widget.initialTab!; - _prioritizeFind = widget.initialTab == 'find'; + _prioritizeFind = _activeTab == ShiftTabType.find; }); } if (widget.selectedDate != null && widget.selectedDate != _selectedDate) { @@ -59,6 +73,10 @@ class _ShiftsPageState extends State { _selectedDate = widget.selectedDate; }); } + if (widget.refreshAvailable) { + _refreshAvailable = true; + _pendingAvailableRefresh = true; + } } @override @@ -68,53 +86,37 @@ class _ShiftsPageState extends State { value: _bloc, child: BlocConsumer( listener: (context, state) { - if (state is ShiftsError) { + if (state.status == ShiftsStatus.error && + state.errorMessage != null) { UiSnackbar.show( context, - message: translateErrorKey(state.message), + message: translateErrorKey(state.errorMessage!), type: UiSnackbarType.error, ); } }, builder: (context, state) { - final bool baseLoaded = state is ShiftsLoaded; - final List myShifts = (state is ShiftsLoaded) - ? state.myShifts - : []; - final List availableJobs = (state is ShiftsLoaded) - ? state.availableShifts - : []; - final bool availableLoading = (state is ShiftsLoaded) - ? state.availableLoading - : false; - final bool availableLoaded = (state is ShiftsLoaded) - ? state.availableLoaded - : false; - final List pendingAssignments = (state is ShiftsLoaded) - ? state.pendingShifts - : []; - final List cancelledShifts = (state is ShiftsLoaded) - ? state.cancelledShifts - : []; - final List historyShifts = (state is ShiftsLoaded) - ? state.historyShifts - : []; - final bool historyLoading = (state is ShiftsLoaded) - ? state.historyLoading - : false; - final bool historyLoaded = (state is ShiftsLoaded) - ? state.historyLoaded - : false; - final bool myShiftsLoaded = (state is ShiftsLoaded) - ? state.myShiftsLoaded - : false; + if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) { + _pendingAvailableRefresh = false; + _bloc.add(const LoadAvailableShiftsEvent(force: true)); + } + final bool baseLoaded = state.status == ShiftsStatus.loaded; + final List myShifts = state.myShifts; + final List availableJobs = state.availableShifts; + final bool availableLoading = state.availableLoading; + final bool availableLoaded = state.availableLoaded; + final List pendingAssignments = state.pendingShifts; + final List cancelledShifts = state.cancelledShifts; + final List historyShifts = state.historyShifts; + final bool historyLoading = state.historyLoading; + final bool historyLoaded = state.historyLoaded; + final bool myShiftsLoaded = state.myShiftsLoaded; final bool blockTabsForFind = _prioritizeFind && !availableLoaded; // Note: "filteredJobs" logic moved to FindShiftsTab // Note: Calendar logic moved to MyShiftsTab return Scaffold( - backgroundColor: UiColors.background, body: Column( children: [ // Header (Blue) @@ -138,32 +140,53 @@ class _ShiftsPageState extends State { // Tabs Row( children: [ + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.myShifts, + t.staff_shifts.tabs.my_shifts, + UiIcons.calendar, + myShifts.length, + showCount: myShiftsLoaded, + enabled: + !blockTabsForFind && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), _buildTab( - "myshifts", - t.staff_shifts.tabs.my_shifts, - UiIcons.calendar, - myShifts.length, - showCount: myShiftsLoaded, - enabled: !blockTabsForFind, - ), - const SizedBox(width: UiConstants.space2), - _buildTab( - "find", + ShiftTabType.find, t.staff_shifts.tabs.find_work, UiIcons.search, availableJobs.length, showCount: availableLoaded, enabled: baseLoaded, ), - const SizedBox(width: UiConstants.space2), - _buildTab( - "history", - t.staff_shifts.tabs.history, - UiIcons.clock, - historyShifts.length, - showCount: historyLoaded, - enabled: !blockTabsForFind && baseLoaded, - ), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.history, + t.staff_shifts.tabs.history, + UiIcons.clock, + historyShifts.length, + showCount: historyLoaded, + enabled: + !blockTabsForFind && + baseLoaded && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), ], ), ], @@ -172,33 +195,33 @@ class _ShiftsPageState extends State { // Body Content Expanded( - child: state is ShiftsLoading + child: state.status == ShiftsStatus.loading ? const Center(child: CircularProgressIndicator()) - : state is ShiftsError - ? Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translateErrorKey(state.message), - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], + : state.status == ShiftsStatus.error + ? Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey(state.errorMessage ?? ''), + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, ), - ), - ) - : _buildTabContent( + ], + ), + ), + ) + : _buildTabContent( myShifts, - pendingAssignments, - cancelledShifts, - availableJobs, - historyShifts, - availableLoading, - historyLoading, - ), + pendingAssignments, + cancelledShifts, + availableJobs, + historyShifts, + availableLoading, + historyLoading, + ), ), ], ), @@ -218,50 +241,48 @@ class _ShiftsPageState extends State { bool historyLoading, ) { switch (_activeTab) { - case 'myshifts': + case ShiftTabType.myShifts: return MyShiftsTab( myShifts: myShifts, pendingAssignments: pendingAssignments, cancelledShifts: cancelledShifts, initialDate: _selectedDate, ); - case 'find': + case ShiftTabType.find: if (availableLoading) { return const Center(child: CircularProgressIndicator()); } return FindShiftsTab(availableJobs: availableJobs); - case 'history': + case ShiftTabType.history: if (historyLoading) { return const Center(child: CircularProgressIndicator()); } return HistoryShiftsTab(historyShifts: historyShifts); - default: - return const SizedBox.shrink(); } } Widget _buildTab( - String id, + ShiftTabType type, String label, IconData icon, int count, { bool showCount = true, bool enabled = true, }) { - final isActive = _activeTab == id; + final isActive = _activeTab == type; return Expanded( child: GestureDetector( onTap: !enabled ? null : () { - setState(() => _activeTab = id); - if (id == 'history') { - _bloc.add(LoadHistoryShiftsEvent()); - } - if (id == 'find') { - _bloc.add(LoadAvailableShiftsEvent()); - } - }, + setState(() => _activeTab = type); + if (type == ShiftTabType.history) { + _bloc.add(LoadHistoryShiftsEvent()); + } + if (type == ShiftTabType.find) { + _bloc.add(LoadAvailableShiftsEvent()); + } + }, child: Container( padding: const EdgeInsets.symmetric( vertical: UiConstants.space2, @@ -290,9 +311,17 @@ class _ShiftsPageState extends State { Flexible( child: Text( label, - style: (isActive ? UiTypography.body3m.copyWith(color: UiColors.primary) : UiTypography.body3m.white).copyWith( - color: !enabled ? UiColors.white.withValues(alpha: 0.5) : null, - ), + style: + (isActive + ? UiTypography.body3m.copyWith( + color: UiColors.primary, + ) + : UiTypography.body3m.white) + .copyWith( + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : null, + ), overflow: TextOverflow.ellipsis, ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/utils/shift_tab_type.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/utils/shift_tab_type.dart new file mode 100644 index 00000000..16576408 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/utils/shift_tab_type.dart @@ -0,0 +1,30 @@ +enum ShiftTabType { + myShifts, + find, + history; + + static ShiftTabType fromString(String? value) { + if (value == null) return ShiftTabType.find; + switch (value.toLowerCase()) { + case 'myshifts': + return ShiftTabType.myShifts; + case 'find': + return ShiftTabType.find; + case 'history': + return ShiftTabType.history; + default: + return ShiftTabType.find; + } + } + + String get id { + switch (this) { + case ShiftTabType.myShifts: + return 'myshifts'; + case ShiftTabType.find: + return 'find'; + case ShiftTabType.history: + return 'history'; + } + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index 86352524..54e82f80 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -27,6 +27,7 @@ class MyShiftCard extends StatefulWidget { } class _MyShiftCardState extends State { + bool _isSubmitted = false; String _formatTime(String time) { if (time.isEmpty) return ''; @@ -77,15 +78,23 @@ class _MyShiftCardState extends State { String _getShiftType() { // Handling potential localization key availability try { - if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) { - return t.staff_shifts.filter.long_term; - } - if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { - return t.staff_shifts.filter.multi_day; - } - return t.staff_shifts.filter.one_day; + final String orderType = (widget.shift.orderType ?? '').toUpperCase(); + if (orderType == 'PERMANENT') { + return t.staff_shifts.filter.long_term; + } + if (orderType == 'RECURRING') { + return t.staff_shifts.filter.multi_day; + } + if (widget.shift.durationDays != null && + widget.shift.durationDays! > 30) { + return t.staff_shifts.filter.long_term; + } + if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { + return t.staff_shifts.filter.multi_day; + } + return t.staff_shifts.filter.one_day; } catch (_) { - return "One Day"; + return "One Day"; } } @@ -103,42 +112,58 @@ class _MyShiftCardState extends State { // Fallback localization if keys missing try { - if (status == 'confirmed') { - statusText = t.staff_shifts.status.confirmed; - statusColor = UiColors.textLink; - statusBg = UiColors.primary; - } else if (status == 'checked_in') { - statusText = 'Checked in'; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'pending' || status == 'open') { - statusText = t.staff_shifts.status.act_now; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; - } else if (status == 'swap') { - statusText = t.staff_shifts.status.swap_requested; - statusColor = UiColors.textWarning; - statusBg = UiColors.textWarning; - statusIcon = UiIcons.swap; - } else if (status == 'completed') { - statusText = t.staff_shifts.status.completed; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'no_show') { - statusText = t.staff_shifts.status.no_show; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; - } + if (status == 'confirmed') { + statusText = t.staff_shifts.status.confirmed; + statusColor = UiColors.textLink; + statusBg = UiColors.primary; + } else if (status == 'checked_in') { + statusText = 'Checked in'; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + } else if (status == 'pending' || status == 'open') { + statusText = t.staff_shifts.status.act_now; + statusColor = UiColors.destructive; + statusBg = UiColors.destructive; + } else if (status == 'swap') { + statusText = t.staff_shifts.status.swap_requested; + statusColor = UiColors.textWarning; + statusBg = UiColors.textWarning; + statusIcon = UiIcons.swap; + } else if (status == 'completed') { + statusText = t.staff_shifts.status.completed; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + } else if (status == 'no_show') { + statusText = t.staff_shifts.status.no_show; + statusColor = UiColors.destructive; + statusBg = UiColors.destructive; + } } catch (_) { - statusText = status?.toUpperCase() ?? ""; + statusText = status?.toUpperCase() ?? ""; } + final schedules = widget.shift.schedules ?? []; + final hasSchedules = schedules.isNotEmpty; + final List visibleSchedules = schedules.length <= 5 + ? schedules + : schedules.take(3).toList(); + final int remainingSchedules = schedules.length <= 5 + ? 0 + : schedules.length - 3; + final String scheduleRange = hasSchedules + ? () { + final first = schedules.first.date; + final last = schedules.last.date; + if (first == last) { + return _formatDate(first); + } + return '${_formatDate(first)} โ€“ ${_formatDate(last)}'; + }() + : ''; + return GestureDetector( onTap: () { - Modular.to.pushNamed( - StaffPaths.shiftDetails(widget.shift.id), - arguments: widget.shift, - ); + Modular.to.toShiftDetails(widget.shift); }, child: Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -146,28 +171,26 @@ class _MyShiftCardState extends State { color: UiColors.white, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], ), child: Padding( padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Status Badge - if (statusText.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Row( - children: [ + // Badge row: shows the status label and the shift-type chip. + // The type chip (One Day / Multi-Day / Long Term) is always + // rendered when orderType is present โ€” even for "open" find-shifts + // cards that may have no meaningful status text. + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Row( + children: [ + if (statusText.isNotEmpty) ...[ if (statusIcon != null) Padding( - padding: const EdgeInsets.only(right: UiConstants.space2), + padding: const EdgeInsets.only( + right: UiConstants.space2, + ), child: Icon( statusIcon, size: UiConstants.iconXs, @@ -178,7 +201,9 @@ class _MyShiftCardState extends State { Container( width: 8, height: 8, - margin: const EdgeInsets.only(right: UiConstants.space2), + margin: const EdgeInsets.only( + right: UiConstants.space2, + ), decoration: BoxDecoration( color: statusBg, shape: BoxShape.circle, @@ -191,29 +216,31 @@ class _MyShiftCardState extends State { letterSpacing: 0.5, ), ), - // Shift Type Badge - if (status == 'open' || status == 'pending') ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusSm, - ), - child: Text( - _getShiftType(), - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - ), - ], + const SizedBox(width: UiConstants.space2), ], - ), + // Type badge โ€” driven by RECURRING / PERMANENT / one-day + // order data and always visible so users can filter + // Find Shifts cards at a glance. + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Text( + _getShiftType(), + style: UiTypography.footnote2m.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ], ), + ), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -231,14 +258,18 @@ class _MyShiftCardState extends State { begin: Alignment.topLeft, end: Alignment.bottomRight, ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), border: Border.all( color: UiColors.primary.withValues(alpha: 0.09), ), ), child: widget.shift.logoUrl != null ? ClipRRect( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), child: Image.network( widget.shift.logoUrl!, fit: BoxFit.contain, @@ -264,8 +295,7 @@ class _MyShiftCardState extends State { children: [ Expanded( child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.shift.title, @@ -299,7 +329,59 @@ class _MyShiftCardState extends State { const SizedBox(height: UiConstants.space2), // Date & Time - if (widget.shift.durationDays != null && + if (hasSchedules) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + scheduleRange, + style: + UiTypography.footnote2r.textSecondary, + ), + ], + ), + + const SizedBox(height: UiConstants.space2), + + Text( + '${schedules.length} schedules', + style: UiTypography.footnote2m.copyWith( + color: UiColors.primary, + ), + ), + const SizedBox(height: UiConstants.space1), + ...visibleSchedules.map( + (schedule) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_formatDate(schedule.date)}, ${_formatTime(schedule.startTime)} โ€“ ${_formatTime(schedule.endTime)}', + style: UiTypography.footnote2r.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + if (remainingSchedules > 0) + Text( + '+$remainingSchedules more schedules', + style: UiTypography.footnote2r.copyWith( + color: UiColors.primary.withValues( + alpha: 0.7, + ), + ), + ), + ], + ), + ] else if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) ...[ Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -324,17 +406,23 @@ class _MyShiftCardState extends State { ), const SizedBox(height: UiConstants.space1), Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} โ€“ ${_formatTime(widget.shift.endTime)}', - style: UiTypography.footnote2r.copyWith(color: UiColors.primary), + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} โ€“ ${_formatTime(widget.shift.endTime)}', + style: UiTypography.footnote2r.copyWith( + color: UiColors.primary, ), + ), ), if (widget.shift.durationDays! > 1) - Text( - '... +${widget.shift.durationDays! - 1} more days', - style: UiTypography.footnote2r.copyWith(color: UiColors.primary.withOpacity(0.7)), - ) + Text( + '... +${widget.shift.durationDays! - 1} more days', + style: UiTypography.footnote2r.copyWith( + color: UiColors.primary.withValues( + alpha: 0.7, + ), + ), + ), ], ), ] else ...[ @@ -391,6 +479,37 @@ class _MyShiftCardState extends State { ), ], ), + if (status == 'completed') ...[ + const SizedBox(height: UiConstants.space4), + const Divider(), + const SizedBox(height: UiConstants.space2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isSubmitted ? 'SUBMITTED' : 'READY TO SUBMIT', + style: UiTypography.footnote2b.copyWith( + color: _isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, + ), + ), + if (!_isSubmitted) + UiButton.secondary( + text: 'Submit for Approval', + size: UiButtonSize.small, + onPressed: () { + setState(() => _isSubmitted = true); + UiSnackbar.show( + context, + message: 'Timesheet submitted for client approval', + type: UiSnackbarType.success, + ); + }, + ) + else + const Icon(UiIcons.success, color: UiColors.iconSuccess, size: 20), + ], + ), + ], ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart index 47eded2f..67e8b4b5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart @@ -6,19 +6,22 @@ import 'package:intl/intl.dart'; class ShiftDateTimeSection extends StatelessWidget { /// The ISO string of the date. final String date; - + + /// The end date string (ISO). + final String? endDate; + /// The start time string (HH:mm). final String startTime; - + /// The end time string (HH:mm). final String endTime; - + /// Localization string for shift date. final String shiftDateLabel; - + /// Localization string for clock in time. final String clockInLabel; - + /// Localization string for clock out time. final String clockOutLabel; @@ -26,6 +29,7 @@ class ShiftDateTimeSection extends StatelessWidget { const ShiftDateTimeSection({ super.key, required this.date, + required this.endDate, required this.startTime, required this.endTime, required this.shiftDateLabel, @@ -63,41 +67,63 @@ class ShiftDateTimeSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - shiftDateLabel, - style: UiTypography.titleUppercase4b.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - UiIcons.calendar, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), Text( - _formatDate(date), - style: UiTypography.headline5m.textPrimary, + shiftDateLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + _formatDate(date), + style: UiTypography.headline5m.textPrimary, + ), + ], ), ], ), - const SizedBox(height: UiConstants.space4), + if (endDate != null) ...[ + const SizedBox(height: UiConstants.space6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SHIFT END DATE', + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + _formatDate(endDate!), + style: UiTypography.headline5m.textPrimary, + ), + ], + ), + ], + ), + ], + const SizedBox(height: UiConstants.space6), Row( children: [ - Expanded( - child: _buildTimeBox( - clockInLabel, - startTime, - ), - ), + Expanded(child: _buildTimeBox(clockInLabel, startTime)), const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildTimeBox( - clockOutLabel, - endTime, - ), - ), + Expanded(child: _buildTimeBox(clockOutLabel, endTime)), ], ), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart index 00eb9578..ccfeae3b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -1,21 +1,21 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_domain/krow_domain.dart'; -import 'package:core_localization/core_localization.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; /// A bottom action bar containing contextual buttons based on shift status. class ShiftDetailsBottomBar extends StatelessWidget { /// The current shift. final Shift shift; - + /// Callback for applying/booking a shift. final VoidCallback onApply; - + /// Callback for declining a shift. final VoidCallback onDecline; - + /// Callback for accepting a shift. final VoidCallback onAccept; @@ -57,21 +57,10 @@ class ShiftDetailsBottomBar extends StatelessWidget { Widget _buildButtons(String status, dynamic i18n, BuildContext context) { if (status == 'confirmed') { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Modular.to.toClockIn(), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.success, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.clock_in, style: UiTypography.body2b.white), - ), + return UiButton.primary( + onPressed: () => Modular.to.toClockIn(), + fullWidth: true, + child: Text(i18n.clock_in, style: UiTypography.body2b.white), ); } @@ -79,36 +68,15 @@ class ShiftDetailsBottomBar extends StatelessWidget { return Row( children: [ Expanded( - child: OutlinedButton( + child: UiButton.secondary( onPressed: onDecline, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - side: const BorderSide(color: UiColors.destructive), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - ), child: Text(i18n.decline, style: UiTypography.body2b.textError), ), ), const SizedBox(width: UiConstants.space4), Expanded( - child: ElevatedButton( + child: UiButton.primary( onPressed: onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), child: Text(i18n.accept_shift, style: UiTypography.body2b.white), ), ), @@ -117,17 +85,9 @@ class ShiftDetailsBottomBar extends StatelessWidget { } if (status == 'open' || status == 'available') { - return ElevatedButton( + return UiButton.primary( onPressed: onApply, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), + fullWidth: true, child: Text(i18n.apply_now, style: UiTypography.body2b.white), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart new file mode 100644 index 00000000..2600c302 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart @@ -0,0 +1,162 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A section displaying the shift type, date range, and weekday schedule summary. +class ShiftScheduleSummarySection extends StatelessWidget { + /// The shift entity. + final Shift shift; + + /// Creates a [ShiftScheduleSummarySection]. + const ShiftScheduleSummarySection({super.key, required this.shift}); + + String _getShiftTypeLabel(Translations t) { + final String type = (shift.orderType ?? '').toUpperCase(); + if (type == 'PERMANENT') { + return t.staff_shifts.filter.long_term; + } + if (type == 'RECURRING') { + return t.staff_shifts.filter.multi_day; + } + return t.staff_shifts.filter.one_day; + } + + bool _isMultiDayOrLongTerm() { + final String type = (shift.orderType ?? '').toUpperCase(); + return type == 'RECURRING' || type == 'PERMANENT'; + } + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + final isMultiDay = _isMultiDayOrLongTerm(); + final typeLabel = _getShiftTypeLabel(t); + final String orderType = (shift.orderType ?? '').toUpperCase(); + + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shift Type Title + UiChip(label: typeLabel, variant: UiChipVariant.secondary), + const SizedBox(height: UiConstants.space2), + + if (isMultiDay) ...[ + // Date Range + if (shift.startDate != null && shift.endDate != null) + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 16, + color: UiColors.textSecondary, + ), + const SizedBox(width: UiConstants.space2), + Text( + '${_formatDate(shift.startDate!)} โ€“ ${_formatDate(shift.endDate!)}', + style: UiTypography.body2m.textPrimary, + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Weekday Circles + _buildWeekdaySchedule(context), + + // Available Shifts Count (Only for RECURRING/Multi-Day) + if (orderType == 'RECURRING' && shift.schedules != null) ...[ + const SizedBox(height: UiConstants.space4), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: UiColors.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + '${shift.schedules!.length} available shifts', + style: UiTypography.body2b.copyWith( + color: UiColors.textSuccess, + ), + ), + ], + ), + ], + ], + ], + ), + ); + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + return DateFormat('MMM d, y').format(date); + } catch (_) { + return dateStr; + } + } + + Widget _buildWeekdaySchedule(BuildContext context) { + final List weekDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + final Set activeDays = _getActiveWeekdayIndices(); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(weekDays.length, (index) { + final bool isActive = activeDays.contains(index); // 1-7 (Mon-Sun) + return Container( + width: 38, + height: 38, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? UiColors.primaryInverse : UiColors.bgThird, + border: Border.all( + color: isActive ? UiColors.primary : UiColors.border, + ), + ), + child: Center( + child: Text( + weekDays[index], + style: UiTypography.body2b.copyWith( + color: isActive ? UiColors.primary : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } + + Set _getActiveWeekdayIndices() { + final List days = shift.recurringDays ?? shift.permanentDays ?? []; + return days.map((day) { + switch (day.toUpperCase()) { + case 'MON': + return DateTime.monday; + case 'TUE': + return DateTime.tuesday; + case 'WED': + return DateTime.wednesday; + case 'THU': + return DateTime.thursday; + case 'FRI': + return DateTime.friday; + case 'SAT': + return DateTime.saturday; + case 'SUN': + return DateTime.sunday; + default: + return -1; + } + }).toSet(); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index bb426fd7..f715ee6c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -1,11 +1,13 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../blocs/shifts/shifts_bloc.dart'; import '../my_shift_card.dart'; import '../shared/empty_state_view.dart'; +import 'package:geolocator/geolocator.dart'; class FindShiftsTab extends StatefulWidget { final List availableJobs; @@ -19,6 +21,227 @@ class FindShiftsTab extends StatefulWidget { class _FindShiftsTabState extends State { String _searchQuery = ''; String _jobType = 'all'; + double? _maxDistance; // miles + Position? _currentPosition; + + @override + void initState() { + super.initState(); + _initLocation(); + } + + Future _initLocation() async { + try { + final LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.always || + permission == LocationPermission.whileInUse) { + final Position pos = await Geolocator.getCurrentPosition(); + if (mounted) { + setState(() => _currentPosition = pos); + } + } + } catch (_) {} + } + + double _calculateDistance(double lat, double lng) { + if (_currentPosition == null) return -1; + final double distMeters = Geolocator.distanceBetween( + _currentPosition!.latitude, + _currentPosition!.longitude, + lat, + lng, + ); + return distMeters / 1609.34; // meters to miles + } + + void _showDistanceFilter() { + showModalBottomSheet( + context: context, + backgroundColor: UiColors.bgPopup, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setModalState) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.staff_shifts.find_shifts.radius_filter_title, + style: UiTypography.headline4m.textPrimary, + ), + const SizedBox(height: 16), + Text( + _maxDistance == null + ? context.t.staff_shifts.find_shifts.unlimited_distance + : context.t.staff_shifts.find_shifts.within_miles( + miles: _maxDistance!.round().toString(), + ), + style: UiTypography.body2m.textSecondary, + ), + Slider( + value: _maxDistance ?? 100, + min: 5, + max: 100, + divisions: 19, + activeColor: UiColors.primary, + onChanged: (double val) { + setModalState(() => _maxDistance = val); + setState(() => _maxDistance = val); + }, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: UiButton.secondary( + text: context.t.staff_shifts.find_shifts.clear, + onPressed: () { + setModalState(() => _maxDistance = null); + setState(() => _maxDistance = null); + Navigator.pop(context); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiButton.primary( + text: context.t.staff_shifts.find_shifts.apply, + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + bool _isRecurring(Shift shift) => + (shift.orderType ?? '').toUpperCase() == 'RECURRING'; + + bool _isPermanent(Shift shift) => + (shift.orderType ?? '').toUpperCase() == 'PERMANENT'; + + DateTime? _parseShiftDate(String date) { + if (date.isEmpty) return null; + try { + return DateTime.parse(date); + } catch (_) { + return null; + } + } + + List _groupMultiDayShifts(List shifts) { + final Map> grouped = >{}; + for (final shift in shifts) { + if (!_isRecurring(shift) && !_isPermanent(shift)) { + continue; + } + final orderId = shift.orderId; + final roleId = shift.roleId; + if (orderId == null || roleId == null) { + continue; + } + final key = '$orderId::$roleId'; + grouped.putIfAbsent(key, () => []).add(shift); + } + + final Set addedGroups = {}; + final List result = []; + + for (final shift in shifts) { + if (!_isRecurring(shift) && !_isPermanent(shift)) { + result.add(shift); + continue; + } + final orderId = shift.orderId; + final roleId = shift.roleId; + if (orderId == null || roleId == null) { + result.add(shift); + continue; + } + final key = '$orderId::$roleId'; + if (addedGroups.contains(key)) { + continue; + } + addedGroups.add(key); + final List group = grouped[key] ?? []; + if (group.isEmpty) { + result.add(shift); + continue; + } + group.sort((a, b) { + final ad = _parseShiftDate(a.date); + final bd = _parseShiftDate(b.date); + if (ad == null && bd == null) return 0; + if (ad == null) return 1; + if (bd == null) return -1; + return ad.compareTo(bd); + }); + + final Shift first = group.first; + final List schedules = group + .map( + (s) => ShiftSchedule( + date: s.date, + startTime: s.startTime, + endTime: s.endTime, + ), + ) + .toList(); + + result.add( + Shift( + id: first.id, + roleId: first.roleId, + title: first.title, + clientName: first.clientName, + logoUrl: first.logoUrl, + hourlyRate: first.hourlyRate, + location: first.location, + locationAddress: first.locationAddress, + date: first.date, + endDate: first.endDate, + startTime: first.startTime, + endTime: first.endTime, + createdDate: first.createdDate, + tipsAvailable: first.tipsAvailable, + travelTime: first.travelTime, + mealProvided: first.mealProvided, + parkingAvailable: first.parkingAvailable, + gasCompensation: first.gasCompensation, + description: first.description, + instructions: first.instructions, + managers: first.managers, + latitude: first.latitude, + longitude: first.longitude, + status: first.status, + durationDays: schedules.length, + requiredSlots: first.requiredSlots, + filledSlots: first.filledSlots, + hasApplied: first.hasApplied, + totalValue: first.totalValue, + breakInfo: first.breakInfo, + orderId: first.orderId, + orderType: first.orderType, + schedules: schedules, + recurringDays: first.recurringDays, + permanentDays: first.permanentDays, + ), + ); + } + + return result; + } Widget _buildFilterTab(String id, String label) { final isSelected = _jobType == id; @@ -49,8 +272,10 @@ class _FindShiftsTabState extends State { @override Widget build(BuildContext context) { + final groupedJobs = _groupMultiDayShifts(widget.availableJobs); + // Filter logic - final filteredJobs = widget.availableJobs.where((s) { + final filteredJobs = groupedJobs.where((s) { final matchesSearch = s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || s.location.toLowerCase().contains(_searchQuery.toLowerCase()) || @@ -58,12 +283,22 @@ class _FindShiftsTabState extends State { if (!matchesSearch) return false; + if (_maxDistance != null && s.latitude != null && s.longitude != null) { + final double dist = _calculateDistance(s.latitude!, s.longitude!); + if (dist > _maxDistance!) return false; + } + if (_jobType == 'all') return true; if (_jobType == 'one-day') { + if (_isRecurring(s) || _isPermanent(s)) return false; return s.durationDays == null || s.durationDays! <= 1; } if (_jobType == 'multi-day') { - return s.durationDays != null && s.durationDays! > 1; + return _isRecurring(s) || + (s.durationDays != null && s.durationDays! > 1); + } + if (_jobType == 'long-term') { + return _isPermanent(s); } return true; }).toList(); @@ -109,7 +344,11 @@ class _FindShiftsTabState extends State { setState(() => _searchQuery = v), decoration: InputDecoration( border: InputBorder.none, - hintText: "Search jobs, location...", + hintText: context + .t + .staff_shifts + .find_shifts + .search_hint, hintStyle: UiTypography.body2r.textPlaceholder, ), ), @@ -119,20 +358,31 @@ class _FindShiftsTabState extends State { ), ), const SizedBox(width: UiConstants.space2), - Container( - height: 48, - width: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + GestureDetector( + onTap: _showDistanceFilter, + child: Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: _maxDistance != null + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all( + color: _maxDistance != null + ? UiColors.primary + : UiColors.border, + ), + ), + child: Icon( + UiIcons.filter, + size: 18, + color: _maxDistance != null + ? UiColors.primary + : UiColors.textSecondary, ), - border: Border.all(color: UiColors.border), - ), - child: const Icon( - UiIcons.filter, - size: 18, - color: UiColors.textSecondary, ), ), ], @@ -143,13 +393,25 @@ class _FindShiftsTabState extends State { scrollDirection: Axis.horizontal, child: Row( children: [ - _buildFilterTab('all', 'All Jobs'), + _buildFilterTab( + 'all', + context.t.staff_shifts.find_shifts.filter_all, + ), const SizedBox(width: UiConstants.space2), - _buildFilterTab('one-day', 'One Day'), + _buildFilterTab( + 'one-day', + context.t.staff_shifts.find_shifts.filter_one_day, + ), const SizedBox(width: UiConstants.space2), - _buildFilterTab('multi-day', 'Multi-Day'), + _buildFilterTab( + 'multi-day', + context.t.staff_shifts.find_shifts.filter_multi_day, + ), const SizedBox(width: UiConstants.space2), - _buildFilterTab('long-term', 'Long Term'), + _buildFilterTab( + 'long-term', + context.t.staff_shifts.find_shifts.filter_long_term, + ), ], ), ), @@ -159,10 +421,10 @@ class _FindShiftsTabState extends State { Expanded( child: filteredJobs.isEmpty - ? const EmptyStateView( + ? EmptyStateView( icon: UiIcons.search, - title: "No jobs available", - subtitle: "Check back later", + title: context.t.staff_shifts.find_shifts.no_jobs_title, + subtitle: context.t.staff_shifts.find_shifts.no_jobs_subtitle, ) : SingleChildScrollView( padding: const EdgeInsets.symmetric( @@ -184,8 +446,11 @@ class _FindShiftsTabState extends State { ); UiSnackbar.show( context, - message: - "Shift application submitted!", // Todo: Localization + message: context + .t + .staff_shifts + .find_shifts + .application_submitted, type: UiSnackbarType.success, ); }, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart index 6b325194..bc24669a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -9,10 +9,7 @@ import '../shared/empty_state_view.dart'; class HistoryShiftsTab extends StatelessWidget { final List historyShifts; - const HistoryShiftsTab({ - super.key, - required this.historyShifts, - }); + const HistoryShiftsTab({super.key, required this.historyShifts}); @override Widget build(BuildContext context) { @@ -33,10 +30,8 @@ class HistoryShiftsTab extends StatelessWidget { (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: GestureDetector( - onTap: () => Modular.to.pushShiftDetails(shift), - child: MyShiftCard( - shift: shift, - ), + onTap: () => Modular.to.toShiftDetails(shift), + child: MyShiftCard(shift: shift), ), ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart index 78fddf80..f22fc524 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -26,6 +26,10 @@ class ShiftDetailsModule extends Module { @override void routes(RouteManager r) { - r.child('/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data)); + r.child( + '/:id', + child: (_) => + ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data), + ); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 02bade2c..5934588f 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'domain/repositories/shifts_repository_interface.dart'; import 'data/repositories_impl/shifts_repository_impl.dart'; import 'domain/usecases/get_my_shifts_usecase.dart'; @@ -12,11 +13,24 @@ import 'domain/usecases/apply_for_shift_usecase.dart'; import 'domain/usecases/get_shift_details_usecase.dart'; import 'presentation/blocs/shifts/shifts_bloc.dart'; import 'presentation/blocs/shift_details/shift_details_bloc.dart'; +import 'presentation/utils/shift_tab_type.dart'; import 'presentation/pages/shifts_page.dart'; class StaffShiftsModule extends Module { @override void binds(Injector i) { + // StaffConnectorRepository for profile completion + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), + ); + + // Profile completion use case + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + // Repository i.add(ShiftsRepositoryImpl.new); @@ -32,7 +46,16 @@ class StaffShiftsModule extends Module { i.add(GetShiftDetailsUseCase.new); // Bloc - i.add(ShiftsBloc.new); + i.add( + () => ShiftsBloc( + getMyShifts: i.get(), + getAvailableShifts: i.get(), + getPendingAssignments: i.get(), + getCancelledShifts: i.get(), + getHistoryShifts: i.get(), + getProfileCompletion: i.get(), + ), + ); i.add(ShiftDetailsBloc.new); } @@ -43,9 +66,11 @@ class StaffShiftsModule extends Module { child: (_) { final args = r.args.data as Map?; final queryParams = r.args.queryParams; + final initialTabStr = queryParams['tab'] ?? args?['initialTab']; return ShiftsPage( - initialTab: queryParams['tab'] ?? args?['initialTab'], + initialTab: ShiftTabType.fromString(initialTabStr), selectedDate: args?['selectedDate'], + refreshAvailable: args?['refreshAvailable'] == true, ); }, ); diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 8315559b..0f23b89c 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: url_launcher: ^6.3.1 firebase_auth: ^6.1.4 firebase_data_connect: ^0.2.2+2 + meta: ^1.17.0 + bloc: ^8.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 9f33afb1..814b5932 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,16 +1,29 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; class StaffMainCubit extends Cubit implements Disposable { - StaffMainCubit() : super(const StaffMainState()) { + StaffMainCubit({ + required GetProfileCompletionUseCase getProfileCompletionUsecase, + }) : _getProfileCompletionUsecase = getProfileCompletionUsecase, + super(const StaffMainState()) { Modular.to.addListener(_onRouteChanged); _onRouteChanged(); } + final GetProfileCompletionUseCase _getProfileCompletionUsecase; + bool _isLoadingCompletion = false; + void _onRouteChanged() { if (isClosed) return; + + // Refresh completion status whenever route changes to catch profile updates + // only if it's not already complete. + refreshProfileCompletion(); + final String path = Modular.to.path; int newIndex = state.currentIndex; @@ -32,6 +45,27 @@ class StaffMainCubit extends Cubit implements Disposable { } } + /// Loads the profile completion status. + Future refreshProfileCompletion() async { + if (_isLoadingCompletion || isClosed) return; + + _isLoadingCompletion = true; + try { + final isComplete = await _getProfileCompletionUsecase(); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: isComplete)); + } + } catch (e) { + // If there's an error, allow access to all features + debugPrint('Error loading profile completion: $e'); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: true)); + } + } finally { + _isLoadingCompletion = false; + } + } + void navigateToTab(int index) { if (index == state.currentIndex) return; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart index 68175302..0903b877 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart @@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart'; class StaffMainState extends Equatable { const StaffMainState({ this.currentIndex = 2, // Default to Home + this.isProfileComplete = false, }); final int currentIndex; + final bool isProfileComplete; - StaffMainState copyWith({int? currentIndex}) { - return StaffMainState(currentIndex: currentIndex ?? this.currentIndex); + StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) { + return StaffMainState( + currentIndex: currentIndex ?? this.currentIndex, + isProfileComplete: isProfileComplete ?? this.isProfileComplete, + ); } @override - List get props => [currentIndex]; + List get props => [currentIndex, isProfileComplete]; } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart deleted file mode 100644 index db753d22..00000000 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart +++ /dev/null @@ -1,16 +0,0 @@ -abstract class StaffMainRoutes { - static const String modulePath = '/worker-main'; - - static const String shifts = '/shifts'; - static const String payments = '/payments'; - static const String home = '/home'; - static const String clockIn = '/clock-in'; - static const String profile = '/profile'; - - // Full paths - static const String shiftsFull = '$modulePath$shifts'; - static const String paymentsFull = '$modulePath$payments'; - static const String homeFull = '$modulePath$home'; - static const String clockInFull = '$modulePath$clockIn'; - static const String profileFull = '$modulePath$profile'; -} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/placeholder_page.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/placeholder_page.dart deleted file mode 100644 index b9d993d6..00000000 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/placeholder_page.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class PlaceholderPage extends StatelessWidget { - const PlaceholderPage({required this.title, super.key}); - - final String title; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(title)), - body: Center(child: Text('$title Page')), - ); - } -} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart index f4479f21..176719ed 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart @@ -1,8 +1,11 @@ import 'dart:ui'; -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:staff_main/src/presentation/blocs/staff_main_cubit.dart'; +import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; +import 'package:staff_main/src/utils/index.dart'; /// A custom bottom navigation bar for the Staff app. /// @@ -10,6 +13,10 @@ import 'package:flutter/material.dart'; /// and follows the KROW Design System guidelines. It displays five tabs: /// Shifts, Payments, Home, Clock In, and Profile. /// +/// Navigation items are gated by profile completion status. Items marked with +/// [StaffNavItem.requireProfileCompletion] are only visible when the profile +/// is complete. +/// /// The widget uses: /// - [UiColors] for all color values /// - [UiTypography] for text styling @@ -36,82 +43,60 @@ class StaffMainBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { - final t = Translations.of(context); // Staff App colors from design system // Using primary (Blue) for active as per prototype const Color activeColor = UiColors.primary; const Color inactiveColor = UiColors.textInactive; - return Stack( - clipBehavior: Clip.none, - children: [ - // Glassmorphic background with blur effect - Positioned.fill( - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.85), - border: Border( - top: BorderSide( - color: UiColors.black.withValues(alpha: 0.1), + return BlocBuilder( + builder: (BuildContext context, StaffMainState state) { + final bool isProfileComplete = state.isProfileComplete; + + return Stack( + clipBehavior: Clip.none, + children: [ + // Glassmorphic background with blur effect + Positioned.fill( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.85), + border: Border( + top: BorderSide( + color: UiColors.black.withValues(alpha: 0.1), + ), + ), ), ), ), ), ), - ), - ), - // Navigation items - Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2, - top: UiConstants.space4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _buildNavItem( - index: 0, - icon: UiIcons.briefcase, - label: t.staff.main.tabs.shifts, - activeColor: activeColor, - inactiveColor: inactiveColor, + // Navigation items + Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2, + top: UiConstants.space4, ), - _buildNavItem( - index: 1, - icon: UiIcons.dollar, - label: t.staff.main.tabs.payments, - activeColor: activeColor, - inactiveColor: inactiveColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ...defaultStaffNavItems.map( + (item) => _buildNavItem( + item: item, + activeColor: activeColor, + inactiveColor: inactiveColor, + isProfileComplete: isProfileComplete, + ), + ), + ], ), - _buildNavItem( - index: 2, - icon: UiIcons.home, - label: t.staff.main.tabs.home, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 3, - icon: UiIcons.clock, - label: t.staff.main.tabs.clock_in, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 4, - icon: UiIcons.users, - label: t.staff.main.tabs.profile, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - ], - ), - ), - ], + ), + ], + ); + }, ); } @@ -122,30 +107,37 @@ class StaffMainBottomBar extends StatelessWidget { /// - Spacing uses [UiConstants.space1] /// - Typography uses [UiTypography.footnote2m] /// - Colors are passed as parameters from design system + /// + /// Items with [item.requireProfileCompletion] = true are hidden when + /// [isProfileComplete] is false. Widget _buildNavItem({ - required int index, - required IconData icon, - required String label, + required StaffNavItem item, required Color activeColor, required Color inactiveColor, + required bool isProfileComplete, }) { - final bool isSelected = currentIndex == index; + // Hide item if profile completion is required but not complete + if (item.requireProfileCompletion && !isProfileComplete) { + return const SizedBox.shrink(); + } + + final bool isSelected = currentIndex == item.index; return Expanded( child: GestureDetector( - onTap: () => onTap(index), + onTap: () => onTap(item.index), behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ Icon( - icon, + item.icon, color: isSelected ? activeColor : inactiveColor, size: UiConstants.iconLg, ), const SizedBox(height: UiConstants.space1), Text( - label, + item.label, style: UiTypography.footnote2m.copyWith( color: isSelected ? activeColor : inactiveColor, ), diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index c40027f1..21493654 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_attire/staff_attire.dart'; import 'package:staff_availability/staff_availability.dart'; import 'package:staff_bank_account/staff_bank_account.dart'; @@ -8,10 +9,12 @@ import 'package:staff_certificates/staff_certificates.dart'; import 'package:staff_clock_in/staff_clock_in.dart'; import 'package:staff_documents/staff_documents.dart'; import 'package:staff_emergency_contact/staff_emergency_contact.dart'; +import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_home/staff_home.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/pages/staff_main_page.dart'; import 'package:staff_payments/staff_payements.dart'; +import 'package:staff_privacy_security/staff_privacy_security.dart'; import 'package:staff_profile/staff_profile.dart'; import 'package:staff_profile_experience/staff_profile_experience.dart'; import 'package:staff_profile_info/staff_profile_info.dart'; @@ -22,7 +25,23 @@ import 'package:staff_time_card/staff_time_card.dart'; class StaffMainModule extends Module { @override void binds(Injector i) { - i.addSingleton(StaffMainCubit.new); + // Register the StaffConnectorRepository from data_connect + i.addSingleton( + StaffConnectorRepositoryImpl.new, + ); + + // Register the use case from data_connect + i.addSingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + i.add( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); } @override @@ -54,7 +73,7 @@ class StaffMainModule extends Module { ], ); r.module( - StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo).replaceFirst('/personal-info', ''), + StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo), module: StaffProfileInfoModule(), ); r.module( @@ -93,9 +112,17 @@ class StaffMainModule extends Module { StaffPaths.childRoute(StaffPaths.main, StaffPaths.availability), module: StaffAvailabilityModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.privacySecurity), + module: PrivacySecurityModule(), + ); r.module( StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute), module: ShiftDetailsModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs), + module: FaqsModule(), + ); } } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart new file mode 100644 index 00000000..f3ec3cae --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart @@ -0,0 +1,2 @@ +export 'staff_nav_item.dart'; +export 'staff_nav_items_config.dart'; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart new file mode 100644 index 00000000..25750d5b --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// Represents a single navigation item in the staff main bottom navigation bar. +/// +/// This data class encapsulates all properties needed to define a navigation item, +/// making it easy to add, remove, or modify items in the bottom bar without +/// touching the UI code. +class StaffNavItem { + /// Creates a [StaffNavItem]. + const StaffNavItem({ + required this.index, + required this.icon, + required this.label, + required this.tabKey, + this.requireProfileCompletion = false, + }); + + /// The index of this navigation item in the bottom bar. + final int index; + + /// The icon to display for this navigation item. + final IconData icon; + + /// The label text to display below the icon. + final String label; + + /// The unique key identifying this tab in the main navigation system. + /// + /// This is used internally for routing and state management. + final String tabKey; + + /// Whether this navigation item requires the user's profile to be complete. + /// + /// If true, this item may be disabled or show a prompt until the profile + /// is fully completed. This is useful for gating access to features that + /// require profile information. + final bool requireProfileCompletion; +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart new file mode 100644 index 00000000..5c328ef2 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:staff_main/src/utils/staff_nav_item.dart'; + +/// Predefined navigation items for the Staff app bottom navigation bar. +/// +/// This list defines all available navigation items. To add, remove, or modify +/// items, simply update this list. The UI will automatically adapt. +final List defaultStaffNavItems = [ + StaffNavItem( + index: 0, + icon: UiIcons.briefcase, + label: 'Shifts', + tabKey: 'shifts', + requireProfileCompletion: false, + ), + StaffNavItem( + index: 1, + icon: UiIcons.dollar, + label: 'Payments', + tabKey: 'payments', + requireProfileCompletion: true, + ), + StaffNavItem( + index: 2, + icon: UiIcons.home, + label: 'Home', + tabKey: 'home', + requireProfileCompletion: false, + ), + StaffNavItem( + index: 3, + icon: UiIcons.clock, + label: 'Clock In', + tabKey: 'clock_in', + requireProfileCompletion: true, + ), + StaffNavItem( + index: 4, + icon: UiIcons.users, + label: 'Profile', + tabKey: 'profile', + requireProfileCompletion: false, + ), +]; diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 2f3788f1..91c0b8a4 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -20,6 +20,10 @@ dependencies: path: ../../../design_system core_localization: path: ../../../core_localization + krow_core: + path: ../../../core + krow_data_connect: + path: ../../../data_connect # Features staff_home: @@ -52,6 +56,10 @@ dependencies: path: ../availability staff_clock_in: path: ../clock_in + staff_privacy_security: + path: ../profile_sections/support/privacy_security + staff_faqs: + path: ../profile_sections/support/faqs dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 25c3fd23..1270ef05 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -337,6 +345,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_app_check: dependency: transitive description: @@ -725,6 +773,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -813,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.257.0" + marionette_flutter: + dependency: transitive + description: + name: marionette_flutter + sha256: "0077073f62a8031879a91be41aa91629f741a7f1348b18feacd53443dae3819f" + url: "https://pub.dev" + source: hosted + version: "0.3.0" matcher: dependency: transitive description: @@ -1290,6 +1410,20 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + staff_faqs: + dependency: transitive + description: + path: "packages/features/staff/profile_sections/support/faqs" + relative: true + source: path + version: "0.0.1" + staff_privacy_security: + dependency: transitive + description: + path: "packages/features/staff/profile_sections/support/privacy_security" + relative: true + source: path + version: "0.0.1" stream_channel: dependency: transitive description: @@ -1482,6 +1616,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1516,4 +1658,4 @@ packages: version: "2.2.3" sdks: dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.38.4 <4.0.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index f1380d0c..350e842f 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -3,6 +3,7 @@ publish_to: 'none' description: "A sample project using melos and modular scaffold." environment: sdk: '>=3.10.0 <4.0.0' + flutter: '>=3.38.0 <4.0.0' workspace: - packages/design_system - packages/core @@ -31,10 +32,12 @@ workspace: - packages/features/client/home - packages/features/client/settings - packages/features/client/hubs - - packages/features/client/create_order - - packages/features/client/view_orders + - packages/features/client/orders/create_order + - packages/features/client/orders/view_orders + - packages/features/client/orders/orders_common - packages/features/client/client_coverage - packages/features/client/client_main + - packages/features/client/reports - apps/staff - apps/client - apps/design_system_viewer diff --git a/backend/dataconnect/connector/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index 4b7db9af..d2a8a205 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -356,6 +356,95 @@ query getApplicationsByStaffId( } } +query getMyApplicationsByStaffId( + $staffId: UUID! + $offset: Int + $limit: Int + $dayStart: Timestamp + $dayEnd: Timestamp +) @auth(level: USER) { + applications( + where: { + staffId: { eq: $staffId } + status: { in: [ CONFIRMED, CHECKED_IN, CHECKED_OUT, LATE, PENDING] } + shift: { + date: { ge: $dayStart, le: $dayEnd } + } + + } + offset: $offset + limit: $limit + ) { + id + shiftId + staffId + status + appliedAt + checkInTime + checkOutTime + origin + createdAt + + shift { + id + title + date + startTime + endTime + location + status + durationDays + description + latitude + longitude + + order { + id + eventName + #location + + teamHub { + address + placeId + hubName + } + + business { + id + businessName + email + contactName + companyLogoUrl + } + vendor { + id + companyName + } + } + + } + + shiftRole { + id + roleId + count + assigned + startTime + endTime + hours + breakType + isBreakPaid + totalValue + role { + id + name + costPerHour + } + } + + } +} + query vaidateDayStaffApplication( $staffId: UUID! $offset: Int @@ -543,6 +632,36 @@ query listAcceptedApplicationsByShiftRoleKey( } } +query listOverlappingAcceptedApplicationsByStaff( + $staffId: UUID! + $newStart: Timestamp! + $newEnd: Timestamp! + $offset: Int + $limit: Int +) @auth(level: USER) { + applications( + where: { + staffId: { eq: $staffId } + status: { in: [ CONFIRMED, CHECKED_IN, CHECKED_OUT, LATE ] } + shiftRole: { + startTime: { lt: $newEnd } + endTime: { gt: $newStart } + } + } + offset: $offset + limit: $limit + orderBy: { appliedAt: ASC } + ) { + id + shiftId + roleId + checkInTime + checkOutTime + staff { id fullName email phone photoUrl } + shiftRole { startTime endTime } + } +} + #getting staffs of an shiftrole status for orders of the day view client query listAcceptedApplicationsByBusinessForDay( $businessId: UUID! @@ -665,10 +784,14 @@ query listCompletedApplicationsByStaffId( durationDays latitude longitude + orderId order { id eventName + orderType + startDate + endDate teamHub { address diff --git a/backend/dataconnect/connector/attireOption/mutations.gql b/backend/dataconnect/connector/attireOption/mutations.gql index 59f4f7f9..8ff9f197 100644 --- a/backend/dataconnect/connector/attireOption/mutations.gql +++ b/backend/dataconnect/connector/attireOption/mutations.gql @@ -1,7 +1,7 @@ mutation createAttireOption( $itemId: String! $label: String! - $icon: String + $description: String $imageUrl: String $isMandatory: Boolean $vendorId: UUID @@ -10,7 +10,7 @@ mutation createAttireOption( data: { itemId: $itemId label: $label - icon: $icon + description: $description imageUrl: $imageUrl isMandatory: $isMandatory vendorId: $vendorId @@ -22,7 +22,7 @@ mutation updateAttireOption( $id: UUID! $itemId: String $label: String - $icon: String + $description: String $imageUrl: String $isMandatory: Boolean $vendorId: UUID @@ -32,7 +32,7 @@ mutation updateAttireOption( data: { itemId: $itemId label: $label - icon: $icon + description: $description imageUrl: $imageUrl isMandatory: $isMandatory vendorId: $vendorId diff --git a/backend/dataconnect/connector/attireOption/queries.gql b/backend/dataconnect/connector/attireOption/queries.gql index 76ce2817..311fe9da 100644 --- a/backend/dataconnect/connector/attireOption/queries.gql +++ b/backend/dataconnect/connector/attireOption/queries.gql @@ -3,7 +3,7 @@ query listAttireOptions @auth(level: USER) { id itemId label - icon + description imageUrl isMandatory vendorId @@ -16,7 +16,7 @@ query getAttireOptionById($id: UUID!) @auth(level: USER) { id itemId label - icon + description imageUrl isMandatory vendorId @@ -39,7 +39,7 @@ query filterAttireOptions( id itemId label - icon + description imageUrl isMandatory vendorId diff --git a/backend/dataconnect/connector/benefitsData/queries.gql b/backend/dataconnect/connector/benefitsData/queries.gql index 2bc60a37..c856fcbf 100644 --- a/backend/dataconnect/connector/benefitsData/queries.gql +++ b/backend/dataconnect/connector/benefitsData/queries.gql @@ -1,4 +1,38 @@ +# ---------------------------------------------------------- +# GET WORKER BENEFIT BALANCES (M4) +# Returns all active benefit plans with balance data for a given worker. +# Supports: Sick Leave (40h), Holidays (24h), Vacation (40h) +# Extensible: any future VendorBenefitPlan will appear automatically. +# +# Fields: +# vendorBenefitPlan.title โ†’ benefit type name +# vendorBenefitPlan.total โ†’ total entitlement (hours) +# current โ†’ used hours +# remaining = total - current โ†’ computed client-side +# ---------------------------------------------------------- +query getWorkerBenefitBalances( + $staffId: UUID! +) @auth(level: USER) { + benefitsDatas( + where: { + staffId: { eq: $staffId } + } + ) { + vendorBenefitPlanId + current + + vendorBenefitPlan { + id + title + description + requestLabel + total + isActive + } + } +} + # ---------------------------------------------------------- # LIST ALL (admin/debug) # ---------------------------------------------------------- diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 32423968..4749c498 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,9 +15,10 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any + $hubManagerId: UUID + $recurringDays: [String!] $permanentStartDate: Timestamp - $permanentDays: Any + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String @@ -40,6 +41,7 @@ mutation createOrder( shifts: $shifts requested: $requested teamHubId: $teamHubId + hubManagerId: $hubManagerId recurringDays: $recurringDays permanentDays: $permanentDays notes: $notes @@ -64,8 +66,8 @@ mutation updateOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any - $permanentDays: Any + $recurringDays: [String!] + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String diff --git a/backend/dataconnect/connector/order/queries.gql b/backend/dataconnect/connector/order/queries.gql index c500c595..f3aad90b 100644 --- a/backend/dataconnect/connector/order/queries.gql +++ b/backend/dataconnect/connector/order/queries.gql @@ -433,3 +433,98 @@ query listOrdersByBusinessAndTeamHub( createdBy } } + +# ------------------------------------------------------------ +# GET COMPLETED ORDERS BY BUSINESS AND DATE RANGE +# ------------------------------------------------------------ +query listCompletedOrdersByBusinessAndDateRange( + $businessId: UUID! + $start: Timestamp! + $end: Timestamp! + $offset: Int + $limit: Int +) @auth(level: USER) { + orders( + where: { + businessId: { eq: $businessId } + status: { eq: COMPLETED } + date: { ge: $start, le: $end } + } + offset: $offset + limit: $limit + orderBy: { createdAt: DESC } + ) { + id + eventName + + vendorId + businessId + orderType + status + date + startDate + endDate + duration + lunchBreak + total + assignedStaff + requested + recurringDays + permanentDays + poReference + notes + createdAt + + business { + id + businessName + email + contactName + } + + vendor { + id + companyName + } + + teamHub { + address + placeId + hubName + } + + # Assigned shifts and their roles + shifts_on_order { + id + title + date + startTime + endTime + hours + cost + location + locationAddress + status + workersNeeded + filled + + shiftRoles_on_shift { + id + roleId + count + assigned + startTime + endTime + hours + totalValue + + role { + id + name + costPerHour + } + } + } + } +} + diff --git a/backend/dataconnect/connector/reports/queries.gql b/backend/dataconnect/connector/reports/queries.gql index 10bceae5..84238101 100644 --- a/backend/dataconnect/connector/reports/queries.gql +++ b/backend/dataconnect/connector/reports/queries.gql @@ -281,7 +281,7 @@ query listInvoicesForSpendByBusiness( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -306,7 +306,7 @@ query listInvoicesForSpendByVendor( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -332,7 +332,7 @@ query listInvoicesForSpendByOrder( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } diff --git a/backend/dataconnect/connector/shiftDayCompletion/mutations.gql b/backend/dataconnect/connector/shiftDayCompletion/mutations.gql new file mode 100644 index 00000000..edaa5b92 --- /dev/null +++ b/backend/dataconnect/connector/shiftDayCompletion/mutations.gql @@ -0,0 +1,129 @@ + +# ------------------------------------------------------------ +# CREATE โ€” called automatically at the end of each shift day +# ------------------------------------------------------------ +mutation createShiftDayCompletion( + $shiftId: UUID! + $orderId: UUID! + $businessId: UUID! + $vendorId: UUID! + $dayDate: Timestamp! + $dayNumber: Int! + $hours: Float + $cost: Float + $staffSummary: Any + $createdBy: String +) @auth(level: USER) { + shiftDayCompletion_insert( + data: { + shiftId: $shiftId + orderId: $orderId + businessId: $businessId + vendorId: $vendorId + dayDate: $dayDate + dayNumber: $dayNumber + status: PENDING_REVIEW + hours: $hours + cost: $cost + staffSummary: $staffSummary + createdBy: $createdBy + } + ) +} + +# ------------------------------------------------------------ +# APPROVE โ€” client approves a daily completion record +# ------------------------------------------------------------ +mutation approveShiftDayCompletion( + $id: UUID! + $reviewedBy: String! + $reviewedAt: Timestamp! +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: APPROVED + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + } + ) +} + +# ------------------------------------------------------------ +# DISPUTE โ€” client disputes a daily completion record +# ------------------------------------------------------------ +mutation disputeShiftDayCompletion( + $id: UUID! + $reviewedBy: String! + $reviewedAt: Timestamp! + $disputeReason: String! + $disputeDetails: String + $disputedItems: Any +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: DISPUTED + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + disputeReason: $disputeReason + disputeDetails: $disputeDetails + disputedItems: $disputedItems + } + ) +} + +# ------------------------------------------------------------ +# LINK INVOICE โ€” set once invoice is generated after full approval +# ------------------------------------------------------------ +mutation linkInvoiceToShiftDayCompletion( + $id: UUID! + $invoiceId: UUID! +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + invoiceId: $invoiceId + } + ) +} + +# ------------------------------------------------------------ +# UPDATE โ€” general-purpose update (admin use) +# ------------------------------------------------------------ +mutation updateShiftDayCompletion( + $id: UUID! + $status: ShiftDayCompletionStatus + $hours: Float + $cost: Float + $staffSummary: Any + $disputeReason: String + $disputeDetails: String + $disputedItems: Any + $reviewedBy: String + $reviewedAt: Timestamp + $invoiceId: UUID +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: $status + hours: $hours + cost: $cost + staffSummary: $staffSummary + disputeReason: $disputeReason + disputeDetails: $disputeDetails + disputedItems: $disputedItems + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + invoiceId: $invoiceId + } + ) +} + +# ------------------------------------------------------------ +# DELETE +# ------------------------------------------------------------ +mutation deleteShiftDayCompletion($id: UUID!) @auth(level: USER) { + shiftDayCompletion_delete(id: $id) +} diff --git a/backend/dataconnect/connector/shiftDayCompletion/queries.gql b/backend/dataconnect/connector/shiftDayCompletion/queries.gql new file mode 100644 index 00000000..3532c03a --- /dev/null +++ b/backend/dataconnect/connector/shiftDayCompletion/queries.gql @@ -0,0 +1,417 @@ + +# ------------------------------------------------------------ +# GET BY ID +# ------------------------------------------------------------ +query getShiftDayCompletionById($id: UUID!) @auth(level: USER) { + shiftDayCompletion(id: $id) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + createdBy + + shift { + id + title + date + startTime + endTime + hours + durationDays + status + } + + order { + id + eventName + orderType + poReference + teamHub { + hubName + address + } + } + + business { + id + businessName + email + contactName + } + + vendor { + id + companyName + email + } + + invoice { + id + invoiceNumber + status + issueDate + dueDate + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL COMPLETION RECORDS FOR A SHIFT +# ------------------------------------------------------------ +query listShiftDayCompletionsByShift( + $shiftId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { shiftId: { eq: $shiftId } } + orderBy: { dayNumber: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL COMPLETION RECORDS FOR AN ORDER +# ------------------------------------------------------------ +query listShiftDayCompletionsByOrder( + $orderId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { orderId: { eq: $orderId } } + orderBy: { dayDate: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST PENDING REVIEW RECORDS FOR A BUSINESS (client view) +# ------------------------------------------------------------ +query listPendingShiftDayCompletionsByBusiness( + $businessId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + businessId: { eq: $businessId } + status: { eq: PENDING_REVIEW } + } + orderBy: { dayDate: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + location + locationAddress + } + + order { + id + eventName + orderType + poReference + teamHub { + hubName + address + } + } + + vendor { + id + companyName + } + } +} + +# ------------------------------------------------------------ +# LIST ALL RECORDS FOR A BUSINESS FILTERED BY STATUS +# ------------------------------------------------------------ +query listShiftDayCompletionsByBusinessAndStatus( + $businessId: UUID! + $status: ShiftDayCompletionStatus! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + businessId: { eq: $businessId } + status: { eq: $status } + } + orderBy: { dayDate: DESC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + order { + id + eventName + orderType + poReference + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL APPROVED RECORDS FOR A SHIFT (invoice trigger check) +# ------------------------------------------------------------ +query listApprovedShiftDayCompletionsByShift( + $shiftId: UUID! +) @auth(level: USER) { + shiftDayCompletions( + where: { + shiftId: { eq: $shiftId } + status: { eq: APPROVED } + } + orderBy: { dayNumber: ASC } + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + durationDays + hours + cost + status + order { + id + eventName + businessId + vendorId + poReference + teamHub { + hubName + address + } + } + } + } +} + +# ------------------------------------------------------------ +# LIST ALL RECORDS BY VENDOR FILTERED BY STATUS +# ------------------------------------------------------------ +query listShiftDayCompletionsByVendorAndStatus( + $vendorId: UUID! + $status: ShiftDayCompletionStatus! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + vendorId: { eq: $vendorId } + status: { eq: $status } + } + orderBy: { dayDate: DESC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + business { + id + businessName + email + } + + invoice { + id + invoiceNumber + status + amount + } + } +} diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index ffba13ae..37a06b67 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -254,7 +254,7 @@ query listShiftRolesByVendorId( shiftRoles( where: { shift: { - status: {in: [IN_PROGRESS, CONFIRMED, ASSIGNED, OPEN, PENDING]} #IN_PROGRESS? PENDING? + status: {in: [IN_PROGRESS, ASSIGNED, OPEN]} #IN_PROGRESS? order: { vendorId: { eq: $vendorId } } @@ -306,6 +306,8 @@ query listShiftRolesByVendorId( orderType status date + startDate + endDate recurringDays permanentDays notes @@ -354,7 +356,7 @@ query listShiftRolesByBusinessAndDateRange( locationAddress title status - order { id eventName } + order { id eventName orderType } } } } @@ -399,14 +401,21 @@ query listShiftRolesByBusinessAndOrder( orderId location locationAddress + filled order{ vendorId eventName date + startDate + endDate + recurringDays + permanentDays + orderType #location teamHub { + id address placeId hubName @@ -417,6 +426,29 @@ query listShiftRolesByBusinessAndOrder( } } +query listShiftRolesByOrderAndRole( + $orderId: UUID! + $roleId: UUID! +) @auth(level: USER) { + shiftRoles( + where: { + shift: { orderId: { eq: $orderId } } + roleId: { eq: $roleId } + } + ) { + id + shiftId + roleId + count + assigned + shift { + id + filled + date + } + } +} + #reorder get list by businessId query listShiftRolesByBusinessDateRangeCompletedOrders( $businessId: UUID! @@ -511,7 +543,7 @@ query getCompletedShiftsByBusinessId( shifts( where: { order: { businessId: { eq: $businessId } } - status: {in: [IN_PROGRESS, CONFIRMED, COMPLETED, OPEN]} + status: {in: [IN_PROGRESS, COMPLETED, OPEN]} date: { ge: $dateFrom, le: $dateTo } } offset: $offset diff --git a/backend/dataconnect/connector/staff/mutations.gql b/backend/dataconnect/connector/staff/mutations.gql index 797ca1bd..23f9b0c7 100644 --- a/backend/dataconnect/connector/staff/mutations.gql +++ b/backend/dataconnect/connector/staff/mutations.gql @@ -214,3 +214,12 @@ mutation UpdateStaff( mutation DeleteStaff($id: UUID!) @auth(level: USER) { staff_delete(id: $id) } + +mutation UpdateStaffProfileVisibility($id: UUID!, $isProfileVisible: Boolean!) @auth(level: USER) { + staff_update( + id: $id + data: { + isProfileVisible: $isProfileVisible + } + ) +} diff --git a/backend/dataconnect/connector/staff/queries/profile_completion.gql b/backend/dataconnect/connector/staff/queries/profile_completion.gql new file mode 100644 index 00000000..a80e0b10 --- /dev/null +++ b/backend/dataconnect/connector/staff/queries/profile_completion.gql @@ -0,0 +1,55 @@ +# ========================================================== +# STAFF PROFILE COMPLETION - QUERIES +# ========================================================== + +query getStaffProfileCompletion($id: UUID!) @auth(level: USER) { + staff(id: $id) { + id + fullName + email + phone + preferredLocations + industries + skills + } + emergencyContacts(where: { staffId: { eq: $id } }) { + id + } + taxForms(where: { staffId: { eq: $id } }) { + id + formType + status + } +} + +query getStaffPersonalInfoCompletion($id: UUID!) @auth(level: USER) { + staff(id: $id) { + id + fullName + email + phone + preferredLocations + } +} + +query getStaffEmergencyProfileCompletion($id: UUID!) @auth(level: USER) { + emergencyContacts(where: { staffId: { eq: $id } }) { + id + } +} + +query getStaffExperienceProfileCompletion($id: UUID!) @auth(level: USER) { + staff(id: $id) { + id + industries + skills + } +} + +query getStaffTaxFormsProfileCompletion($id: UUID!) @auth(level: USER) { + taxForms(where: { staffId: { eq: $id } }) { + id + formType + status + } +} diff --git a/backend/dataconnect/connector/staff/queries.gql b/backend/dataconnect/connector/staff/queries/queries.gql similarity index 95% rename from backend/dataconnect/connector/staff/queries.gql rename to backend/dataconnect/connector/staff/queries/queries.gql index aecf8891..61bb7113 100644 --- a/backend/dataconnect/connector/staff/queries.gql +++ b/backend/dataconnect/connector/staff/queries/queries.gql @@ -204,3 +204,10 @@ query filterStaff( zipCode } } + +query getStaffProfileVisibility($staffId: UUID!) @auth(level: USER) { + staff(id: $staffId) { + id + isProfileVisible + } +} diff --git a/backend/dataconnect/connector/staffAttire/mutations.gql b/backend/dataconnect/connector/staffAttire/mutations.gql new file mode 100644 index 00000000..72fa489b --- /dev/null +++ b/backend/dataconnect/connector/staffAttire/mutations.gql @@ -0,0 +1,17 @@ +mutation upsertStaffAttire( + $staffId: UUID! + $attireOptionId: UUID! + $verificationPhotoUrl: String + $verificationId: String + $verificationStatus: AttireVerificationStatus +) @auth(level: USER) { + staffAttire_upsert( + data: { + staffId: $staffId + attireOptionId: $attireOptionId + verificationPhotoUrl: $verificationPhotoUrl + verificationId: $verificationId + verificationStatus: $verificationStatus + } + ) +} diff --git a/backend/dataconnect/connector/staffAttire/queries.gql b/backend/dataconnect/connector/staffAttire/queries.gql new file mode 100644 index 00000000..bb7d097c --- /dev/null +++ b/backend/dataconnect/connector/staffAttire/queries.gql @@ -0,0 +1,8 @@ +query getStaffAttire($staffId: UUID!) @auth(level: USER) { + staffAttires(where: { staffId: { eq: $staffId } }) { + attireOptionId + verificationStatus + verificationPhotoUrl + verificationId + } +} diff --git a/backend/dataconnect/connector/user/mutations.gql b/backend/dataconnect/connector/user/mutations.gql index 05e233b6..f29b62d9 100644 --- a/backend/dataconnect/connector/user/mutations.gql +++ b/backend/dataconnect/connector/user/mutations.gql @@ -1,6 +1,7 @@ mutation CreateUser( $id: String!, # Firebase UID $email: String, + $phone: String, $fullName: String, $role: UserBaseRole!, $userRole: String, @@ -10,6 +11,7 @@ mutation CreateUser( data: { id: $id email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole @@ -21,6 +23,7 @@ mutation CreateUser( mutation UpdateUser( $id: String!, $email: String, + $phone: String, $fullName: String, $role: UserBaseRole, $userRole: String, @@ -30,6 +33,7 @@ mutation UpdateUser( id: $id, data: { email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole diff --git a/backend/dataconnect/connector/user/queries.gql b/backend/dataconnect/connector/user/queries.gql index 044abebf..760d633f 100644 --- a/backend/dataconnect/connector/user/queries.gql +++ b/backend/dataconnect/connector/user/queries.gql @@ -2,6 +2,7 @@ query listUsers @auth(level: USER) { users { id email + phone fullName role userRole @@ -17,6 +18,7 @@ query getUserById( user(id: $id) { id email + phone fullName role userRole @@ -40,6 +42,7 @@ query filterUsers( ) { id email + phone fullName role userRole diff --git a/backend/dataconnect/functions/cleanAttire.gql b/backend/dataconnect/functions/cleanAttire.gql new file mode 100644 index 00000000..69b689a0 --- /dev/null +++ b/backend/dataconnect/functions/cleanAttire.gql @@ -0,0 +1,3 @@ +mutation cleanAttireOptions @transaction { + attireOption_deleteMany(all: true) +} diff --git a/backend/dataconnect/functions/seed.gql b/backend/dataconnect/functions/seed.gql index 8c6b69c0..065a8246 100644 --- a/backend/dataconnect/functions/seed.gql +++ b/backend/dataconnect/functions/seed.gql @@ -927,7 +927,7 @@ mutation seedAll @transaction { placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" latitude: 34.2611486 longitude: -118.5010287 - status: ASSIGNED + status: OPEN workersNeeded: 2 filled: 1 } @@ -950,7 +950,7 @@ mutation seedAll @transaction { placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" latitude: 34.2611486 longitude: -118.5010287 - status: ASSIGNED + status: OPEN workersNeeded: 2 filled: 1 } @@ -996,7 +996,7 @@ mutation seedAll @transaction { placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" latitude: 34.2611486 longitude: -118.5010287 - status: ASSIGNED + status: OPEN workersNeeded: 2 filled: 1 } @@ -1042,7 +1042,7 @@ mutation seedAll @transaction { placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" latitude: 34.2611486 longitude: -118.5010287 - status: ASSIGNED + status: OPEN workersNeeded: 2 filled: 1 } @@ -1770,5 +1770,163 @@ mutation seedAll @transaction { invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee" } ) + + # Attire Options (Required) + attire_1: attireOption_insert( + data: { + id: "4bce6592-e38e-4d90-a478-d1ce0f286146" + itemId: "shoes_non_slip" + label: "Non Slip Shoes" + description: "Black, closed-toe, non-slip work shoes." + imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_2: attireOption_insert( + data: { + id: "786e9761-b398-42bd-b363-91a40938864e" + itemId: "pants_black" + label: "Black Pants" + description: "Professional black slacks or trousers. No jeans." + imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_3: attireOption_insert( + data: { + id: "17b135e6-b8f0-4541-b12b-505e95de31ef" + itemId: "socks_black" + label: "Black Socks" + description: "Solid black dress or crew socks." + imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_4: attireOption_insert( + data: { + id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517" + itemId: "shirt_white_button_up" + label: "White Button Up" + description: "Clean, pressed, long-sleeve white button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + + # Attire Options (Non-Essential) + attire_5: attireOption_insert( + data: { + id: "32e77813-24f5-495b-98de-872e33073820" + itemId: "pants_blue_jeans" + label: "Blue Jeans" + description: "Standard blue denim jeans, no rips or tears." + imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_6: attireOption_insert( + data: { + id: "de3c5a90-2c88-4c87-bb00-b62c6460d506" + itemId: "shirt_white_polo" + label: "White Polo" + description: "White polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_7: attireOption_insert( + data: { + id: "64149864-b886-4a00-9aa2-09903a401b5b" + itemId: "shirt_catering" + label: "Catering Shirt" + description: "Company approved catering staff shirt." + imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_8: attireOption_insert( + data: { + id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076" + itemId: "banquette" + label: "Banquette" + description: "Standard banquette or event setup uniform." + imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_9: attireOption_insert( + data: { + id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248" + itemId: "hat_black_cap" + label: "Black Cap" + description: "Plain black baseball cap, no logos." + imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_10: attireOption_insert( + data: { + id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6" + itemId: "chef_coat" + label: "Chef Coat" + description: "Standard white double-breasted chef coat." + imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_11: attireOption_insert( + data: { + id: "d857d96b-5bf4-4648-bb9c-f909436729fd" + itemId: "shirt_black_button_up" + label: "Black Button Up" + description: "Clean, pressed, long-sleeve black button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_12: attireOption_insert( + data: { + id: "1f61267b-1f7a-43f1-bfd7-2a018347285b" + itemId: "shirt_black_polo" + label: "Black Polo" + description: "Black polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_13: attireOption_insert( + data: { + id: "16192098-e5ec-4bf2-86d3-c693663BA687" + itemId: "all_black_bistro" + label: "All Black Bistro" + description: "Full black bistro uniform including apron." + imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_14: attireOption_insert( + data: { + id: "6be15ab9-6c73-453b-950b-d4ba35d875de" + itemId: "white_black_bistro" + label: "White and Black Bistro" + description: "White shirt with black pants and bistro apron." + imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) } #v.3 \ No newline at end of file diff --git a/backend/dataconnect/functions/seedAttire.gql b/backend/dataconnect/functions/seedAttire.gql new file mode 100644 index 00000000..fa9f9870 --- /dev/null +++ b/backend/dataconnect/functions/seedAttire.gql @@ -0,0 +1,159 @@ +mutation seedAttireOptions @transaction { + # Attire Options (Required) + attire_1: attireOption_upsert( + data: { + id: "4bce6592-e38e-4d90-a478-d1ce0f286146" + itemId: "shoes_non_slip" + label: "Non Slip Shoes" + description: "Black, closed-toe, non-slip work shoes." + imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_2: attireOption_upsert( + data: { + id: "786e9761-b398-42bd-b363-91a40938864e" + itemId: "pants_black" + label: "Black Pants" + description: "Professional black slacks or trousers. No jeans." + imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_3: attireOption_upsert( + data: { + id: "17b135e6-b8f0-4541-b12b-505e95de31ef" + itemId: "socks_black" + label: "Black Socks" + description: "Solid black dress or crew socks." + imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_4: attireOption_upsert( + data: { + id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517" + itemId: "shirt_white_button_up" + label: "White Button Up" + description: "Clean, pressed, long-sleeve white button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + + # Attire Options (Non-Essential) + attire_5: attireOption_upsert( + data: { + id: "32e77813-24f5-495b-98de-872e33073820" + itemId: "pants_blue_jeans" + label: "Blue Jeans" + description: "Standard blue denim jeans, no rips or tears." + imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_6: attireOption_upsert( + data: { + id: "de3c5a90-2c88-4c87-bb00-b62c6460d506" + itemId: "shirt_white_polo" + label: "White Polo" + description: "White polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_7: attireOption_upsert( + data: { + id: "64149864-b886-4a00-9aa2-09903a401b5b" + itemId: "shirt_catering" + label: "Catering Shirt" + description: "Company approved catering staff shirt." + imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_8: attireOption_upsert( + data: { + id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076" + itemId: "banquette" + label: "Banquette" + description: "Standard banquette or event setup uniform." + imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_9: attireOption_upsert( + data: { + id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248" + itemId: "hat_black_cap" + label: "Black Cap" + description: "Plain black baseball cap, no logos." + imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_10: attireOption_upsert( + data: { + id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6" + itemId: "chef_coat" + label: "Chef Coat" + description: "Standard white double-breasted chef coat." + imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_11: attireOption_upsert( + data: { + id: "d857d96b-5bf4-4648-bb9c-f909436729fd" + itemId: "shirt_black_button_up" + label: "Black Button Up" + description: "Clean, pressed, long-sleeve black button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_12: attireOption_upsert( + data: { + id: "1f61267b-1f7a-43f1-bfd7-2a018347285b" + itemId: "shirt_black_polo" + label: "Black Polo" + description: "Black polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_13: attireOption_upsert( + data: { + id: "16192098-e5ec-4bf2-86d3-c693663BA687" + itemId: "all_black_bistro" + label: "All Black Bistro" + description: "Full black bistro uniform including apron." + imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_14: attireOption_upsert( + data: { + id: "6be15ab9-6c73-453b-950b-d4ba35d875de" + itemId: "white_black_bistro" + label: "White and Black Bistro" + description: "White shirt with black pants and bistro apron." + imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) +} \ No newline at end of file diff --git a/backend/dataconnect/schema/attireOption.gql b/backend/dataconnect/schema/attireOption.gql index 2c09a410..8edf8254 100644 --- a/backend/dataconnect/schema/attireOption.gql +++ b/backend/dataconnect/schema/attireOption.gql @@ -2,7 +2,7 @@ type AttireOption @table(name: "attire_options") { id: UUID! @default(expr: "uuidV4()") itemId: String! label: String! - icon: String + description: String imageUrl: String isMandatory: Boolean diff --git a/backend/dataconnect/schema/benefitsData.gql b/backend/dataconnect/schema/benefitsData.gql index 397d80f3..50a075d8 100644 --- a/backend/dataconnect/schema/benefitsData.gql +++ b/backend/dataconnect/schema/benefitsData.gql @@ -1,13 +1,15 @@ -type BenefitsData @table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) { +type BenefitsData + @table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) { id: UUID! @default(expr: "uuidV4()") - vendorBenefitPlanId: UUID! - vendorBenefitPlan: VendorBenefitPlan! @ref( fields: "vendorBenefitPlanId", references: "id" ) + vendorBenefitPlanId: UUID! + vendorBenefitPlan: VendorBenefitPlan! + @ref(fields: "vendorBenefitPlanId", references: "id") current: Int! staffId: UUID! - staff: Staff! @ref( fields: "staffId", references: "id" ) + staff: Staff! @ref(fields: "staffId", references: "id") createdAt: Timestamp @default(expr: "request.time") updatedAt: Timestamp @default(expr: "request.time") diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 1c815e60..056c9369 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -47,15 +47,18 @@ type Order @table(name: "orders", key: ["id"]) { teamHubId: UUID! teamHub: TeamHub! @ref(fields: "teamHubId", references: "id") + hubManagerId: UUID + hubManager: TeamMember @ref(fields: "hubManagerId", references: "id") + date: Timestamp startDate: Timestamp #for recurring and permanent endDate: Timestamp #for recurring and permanent - recurringDays: Any @col(dataType: "jsonb") + recurringDays: [String!] poReference: String - permanentDays: Any @col(dataType: "jsonb") + permanentDays: [String!] detectedConflicts: Any @col(dataType:"jsonb") notes: String diff --git a/backend/dataconnect/schema/shift.gql b/backend/dataconnect/schema/shift.gql index 3e5f7c67..f03e54a7 100644 --- a/backend/dataconnect/schema/shift.gql +++ b/backend/dataconnect/schema/shift.gql @@ -1,9 +1,7 @@ enum ShiftStatus { DRAFT FILLED - PENDING ASSIGNED - CONFIRMED OPEN IN_PROGRESS COMPLETED diff --git a/backend/dataconnect/schema/shiftDayCompletion.gql b/backend/dataconnect/schema/shiftDayCompletion.gql new file mode 100644 index 00000000..a990edb7 --- /dev/null +++ b/backend/dataconnect/schema/shiftDayCompletion.gql @@ -0,0 +1,45 @@ +enum ShiftDayCompletionStatus { + PENDING_REVIEW + APPROVED + DISPUTED +} + +type ShiftDayCompletion @table(name: "shift_day_completions", key: ["id"]) { + id: UUID! @default(expr: "uuidV4()") + + shiftId: UUID! + shift: Shift! @ref(fields: "shiftId", references: "id") + + orderId: UUID! + order: Order! @ref(fields: "orderId", references: "id") + + businessId: UUID! + business: Business! @ref(fields: "businessId", references: "id") + + vendorId: UUID! + vendor: Vendor! @ref(fields: "vendorId", references: "id") + + dayDate: Timestamp! + dayNumber: Int! + + status: ShiftDayCompletionStatus! @default(expr: "'PENDING_REVIEW'") + + hours: Float + cost: Float + + staffSummary: Any @col(dataType: "jsonb") + + disputeReason: String + disputeDetails: String + disputedItems: Any @col(dataType: "jsonb") + + reviewedBy: String + reviewedAt: Timestamp + + invoiceId: UUID + invoice: Invoice @ref(fields: "invoiceId", references: "id") + + createdAt: Timestamp @default(expr: "request.time") + updatedAt: Timestamp @default(expr: "request.time") + createdBy: String +} diff --git a/backend/dataconnect/schema/staffAttire.gql b/backend/dataconnect/schema/staffAttire.gql new file mode 100644 index 00000000..c3f0e213 --- /dev/null +++ b/backend/dataconnect/schema/staffAttire.gql @@ -0,0 +1,27 @@ +enum AttireVerificationStatus { + PENDING + PROCESSING + AUTO_PASS + AUTO_FAIL + NEEDS_REVIEW + APPROVED + REJECTED + ERROR +} + +type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"]) { + staffId: UUID! + staff: Staff! @ref(fields: "staffId", references: "id") + + attireOptionId: UUID! + attireOption: AttireOption! @ref(fields: "attireOptionId", references: "id") + + # Verification Metadata + verificationStatus: AttireVerificationStatus @default(expr: "'PENDING'") + verifiedAt: Timestamp + verificationPhotoUrl: String # Proof of ownership + verificationId: String + + createdAt: Timestamp @default(expr: "request.time") + updatedAt: Timestamp @default(expr: "request.time") +} diff --git a/backend/dataconnect/schema/user.gql b/backend/dataconnect/schema/user.gql index 4cb4ca24..4d932493 100644 --- a/backend/dataconnect/schema/user.gql +++ b/backend/dataconnect/schema/user.gql @@ -6,6 +6,7 @@ enum UserBaseRole { type User @table(name: "users") { id: String! # user_id / uid de Firebase email: String + phone: String fullName: String role: UserBaseRole! userRole: String diff --git a/docs/ARCHITECTURE/architecture.md b/docs/ARCHITECTURE/architecture.md new file mode 100644 index 00000000..b84f9861 --- /dev/null +++ b/docs/ARCHITECTURE/architecture.md @@ -0,0 +1,152 @@ +# Krow Platform: System Architecture Overview + +## 1. Executive Summary: The Business Purpose +The **Krow Platform** is an end-to-end workforce management ecosystem designed to bridge the gap between businesses that need staff ("Clients") and the temporary workers who fill those roles ("Staff"). + +Traditionally, this process involves phone calls, paper timesheets, and manual payroll. Krow digitizes the entire lifecycle: +1. **Finding Work:** Clients post shifts instantly; workers claim them via mobile. +2. **Doing Work:** GPS-verified clock-ins and digital timesheets ensure accuracy. +3. **Managing Business:** A web dashboard provides analytics, billing, and compliance oversight. + +The system's goal is to reduce administrative friction, ensure legal compliance, and optimize labor costs through automation and real-time data. + +## 2. The Application Ecosystem +The platform consists of three distinct applications, each tailored to a specific user group: + +### A. Client Mobile App (The "Requester") +* **User:** Business Owners, Venue Managers. +* **Role:** The demand generator. It allows clients to request staff on the fly, track who is arriving, and approve hours worked. +* **Key Value:** Speed and visibility. A manager can fill a sudden "no-show" gap in seconds from their phone. + +### B. Staff Mobile App (The "Worker") +* **User:** Temporary Staff (Servers, Cooks, Bartenders). +* **Role:** The supply pool. It acts as their personal agency, handling job discovery, schedule management, and instant payouts. +* **Key Value:** Flexibility and financial security. Workers choose when they work and get paid faster. + +### C. Krow Web Application (The "HQ") +* **User:** Administrators, HR, Finance, and Client Executives. +* **Role:** The command center. It handles the heavy liftingโ€”complex invoicing, vendor management, compliance audits, and strategic data analysis. +* **Key Value:** Control and insight. It turns operational data into cost-saving strategies. + +## 3. How the Applications Interact +The three applications do not "talk" directly to each other (e.g., the staff app doesn't send a message directly to the client app). Instead, they all communicate with a central **Shared Backend System** (The "Brain"). + +* **Scenario: Filling a Shift** + 1. **Client App:** Manager posts a shift for "Friday, 6 PM". + 2. **Backend:** Receives the request, validates it, and notifies eligible workers. + 3. **Staff App:** Worker sees the notification and taps "Accept". + 4. **Backend:** Confirms the match, updates the schedule, and alerts the client. + 5. **Web App:** Admin sees the shift status change from "Open" to "Filled" on the live dashboard. + +## 4. Shared Services & Infrastructure +To function as a cohesive unit, the ecosystem relies on several shared foundational services: + +* **Central Database:** The "Single Source of Truth." Whether a worker updates their profile photo on mobile or an admin updates it on the web, the change is saved in one place (Firebase/Firestore) and reflects everywhere instantly. +* **Authentication Service:** A unified login system. While users have different roles (Client vs. Staff), the security mechanism verifying their identity is shared. +* **Notification Engine:** A centralized service that knows how to reach usersโ€”sending push notifications to phones (Mobile Apps) and emails to desktops (Web App). +* **Payment Gateway:** A shared financial pipe. It collects money from clients (Credit Card/ACH) and disburses it to workers (Direct Deposit/Instant Pay). + +## 5. Data Ownership & Boundaries +To maintain privacy and organization, data is strictly compartmentalized: + +* **Worker Data:** Owned by the worker but accessible to the platform. Clients can only see limited details (Name, Rating, Skills) of workers assigned to *their* specific shifts. They cannot see a worker's full financial history or assignments with other clients. +* **Client Data:** Owned by the business. Workers see only what is necessary to do the job (Location, Dress Code, Supervisor Name). They cannot see the client's internal billing or strategic reports. +* **Platform Data:** owned by Krow (Admins). This includes the aggregate data used for "Smart Strategies" and market analysisโ€”e.g., "Average hourly rate for a Bartender in downtown." + +## 6. Security & Access Control +The system operates on a **Role-Based Access Control (RBAC)** model: + +* **Authentication (Who are you?):** Strict verification using email/password or phone/OTP (One-Time Password). +* **Authorization (What can you do?):** + * **Staff:** Can *read* job details but *write* only to their own timesheets and profile. + * **Clients:** Can *write* new orders and *read* reports for their own venues only. + * **Admins:** Have "Super User" privileges to view and modify data across the entire system to resolve disputes or manage configurations. + +## 7. Inter-Application Dependencies +While the apps are installed separately, they are operationally dependent: + +* **Dependency A:** The **Client App** cannot function without the **Staff App** users. An order posted by a client is useless if no workers exist to claim it. +* **Dependency B:** The **Staff App** relies on the **Web App** for financial processing. A worker can "clock out," but they don't get paid until the backend logic (managed via Web App rules) processes the invoice. +* **Dependency C:** All apps depend on the **Backend API**. If the central server goes down, no app can fetch data, effectively pausing the entire operation. + +--- + +# System Overview Diagram +```mermaid +flowchart LR + subgraph Users [Users] + ClientUser((Client Manager)) + StaffUser((Temporary Worker)) + AdminUser((Admin / Ops)) + end + + subgraph FrontEnd [Application Ecosystem] + direction TB + ClientApp[Client Mobile App] + StaffApp[Staff Mobile App] + WebApp[Web Management Console] + end + + subgraph Backend [Shared Backend Services] + direction TB + APIGateway[API Gateway] + + subgraph CoreServices [Core Business Logic] + AuthService[Authentication Service] + OrderService[Order & Shift Service] + WorkerService[Worker Profile Service] + FinanceService[Billing & Payroll Service] + NotificationEngine[Notification Engine] + AnalyticsEngine[Analytics & AI Engine] + end + + Database[("Central Database (Firebase/Firestore)")] + end + + subgraph External [External Integrations] + PaymentProvider["Payment Gateway (Stripe/Bank)"] + MapService[Maps & Geolocation] + SMSGateway[SMS / OTP Service] + end + + %% User Interactions + ClientUser -- Uses --> ClientApp + StaffUser -- Uses --> StaffApp + AdminUser -- Uses --> WebApp + + %% App to Backend Communication + ClientApp -- "Auth, Orders, Timesheets" --> APIGateway + StaffApp -- "Auth, Job Claims, Clock-In" --> APIGateway + WebApp -- "Auth, Admin, Reports" --> APIGateway + + %% Internal Backend Flow + APIGateway --> CoreServices + CoreServices --> Database + + %% Specific Service Interactions + AuthService -- "Verifies Identity" --> Database + OrderService -- "Matches Shifts" --> Database + WorkerService -- "Stores Profiles" --> Database + FinanceService -- "Processes Invoices" --> Database + AnalyticsEngine -- "Reads Data" --> Database + + %% External Connections + AuthService -- "Sends OTP" --> SMSGateway + StaffApp -- "Verifies Location" --> MapService + FinanceService -- "Processes Payouts" --> PaymentProvider + NotificationEngine -- "Push Alerts" --> ClientApp + NotificationEngine -- "Push Alerts" --> StaffApp + + %% Styling + classDef user fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef app fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; + classDef backend fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; + classDef external fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; + classDef db fill:#e0e0e0,stroke:#616161,stroke-width:2px; + + class ClientUser,StaffUser,AdminUser user; + class ClientApp,StaffApp,WebApp app; + class APIGateway,AuthService,OrderService,WorkerService,FinanceService,NotificationEngine,AnalyticsEngine backend; + class PaymentProvider,MapService,SMSGateway external; + class Database db; +``` diff --git a/docs/ARCHITECTURE/client-mobile-application/use-case.md b/docs/ARCHITECTURE/client-mobile-application/use-case.md new file mode 100644 index 00000000..9223f6e6 --- /dev/null +++ b/docs/ARCHITECTURE/client-mobile-application/use-case.md @@ -0,0 +1,209 @@ +# Client Application: Use Case Overview + +This document details the primary business actions and user flows within the **Client Mobile Application**. It is organized according to the application's logical structure and navigation flow. + +--- + +## 1. Application Access & Authentication + +### 1.1 Initial Startup & Auth Check +* **Actor:** Business Manager +* **Description:** The system determines if the user is already logged in or needs to authenticate. +* **Main Flow:** + 1. User opens the application. + 2. System checks for a valid session. + 3. If authenticated, user is directed to the **Home Dashboard**. + 4. If unauthenticated, user is directed to the **Get Started** screen. + +### 1.2 Register Business Account (Sign Up) +* **Actor:** New Business Manager +* **Description:** Creating a new identity for the business on the Krow platform. +* **Main Flow:** + 1. User taps "Sign Up". + 2. User enters company details (Name, Industry). + 3. User enters contact information and password. + 4. User confirms registration and is directed to the Main App. + +### 1.3 Business Sign In +* **Actor:** Existing Business Manager +* **Description:** Accessing an existing account. +* **Main Flow:** + 1. User enters registered email and password. + 2. System validates credentials. + 3. User is granted access to the dashboard. + +--- + +## 2. Order Management (Requesting Staff) + +### 2.1 Rapid Order (Urgent Needs) +* **Actor:** Business Manager +* **Description:** Posting a shift for immediate, same-day fulfillment. +* **Main Flow:** Taps "RAPID" -> Selects Role -> Sets Quantity -> Confirms ASAP status -> Posts Order. + +### 2.2 Scheduled Orders (Planned Needs) +* **Actor:** Business Manager +* **Description:** Planning for future staffing requirements. +* **Main Flow:** + 1. User selects "Create Order". + 2. User chooses the frequency: + * **One-Time:** A single specific shift. + * **Recurring:** Shifts that repeat on a schedule (e.g., every Monday). + * **Permanent:** Long-term staffing placement. + 3. User enters date, time, role, and location. + 4. User reviews cost and posts the order. + +--- + +## 3. Operations & Workforce Management + +### 3.1 Monitor Today's Coverage (Coverage Tab) +* **Actor:** Business Manager +* **Description:** A bird's-eye view of all shifts happening today. +* **Main Flow:** User navigates to "Coverage" tab -> Views percentage filled -> Identifies open gaps -> Re-posts unfilled shifts if necessary. + +### 3.2 Live Activity Tracking +* **Actor:** Business Manager +* **Description:** Real-time feed of worker clock-ins and status updates. +* **Location:** Home Tab / Coverage Detail. +* **Main Flow:** User monitors the live feed for worker arrivals and on-site status. + +### 3.3 Verify Worker Attire +* **Actor:** Business Manager / Site Supervisor +* **Description:** Ensuring staff arriving on-site meet the required dress code. +* **Main Flow:** User selects an active shift -> Selects worker -> Checks attire compliance (shoes, uniform, ID) -> Submits verification. + +### 3.4 Review & Approve Timesheets +* **Actor:** Business Manager +* **Description:** Finalizing hours worked for payroll processing. +* **Main Flow:** User navigates to "Timesheets" -> Reviews actual vs. scheduled hours -> Taps "Approve" or "Dispute". + +--- + +## 4. Reports & Analytics + +### 4.1 Business Intelligence Reporting +* **Actor:** Business Manager / Executive +* **Description:** Accessing data visualizations to optimize business operations. +* **Available Reports:** + * **Daily Ops:** Day-to-day fulfillment and performance. + * **Spend Report:** Financial breakdown of labor costs. + * **Forecast:** Projected staffing needs and costs. + * **Performance:** Worker and vendor reliability scores. + * **No-Show:** Tracking attendance issues. + * **Coverage:** Detailed fill-rate analysis. + +--- + +## 5. Billing & Administration + +### 5.1 Financial Management (Billing Tab) +* **Actor:** Business Manager / Finance Admin +* **Description:** Reviewing invoices and managing payment methods. +* **Main Flow:** User navigates to "Billing" -> Views current balance -> Downloads past invoices -> Updates credit card/ACH info. + +### 5.2 Manage Business Locations (Hubs) +* **Actor:** Business Manager +* **Description:** Defining different venues or branches where staff will be sent. +* **Main Flow:** User goes to Settings -> Client Hubs -> Adds/Edits location details and addresses. + +### 5.3 Profile & Settings Management +* **Actor:** Business Manager +* **Description:** Updating personal contact info and notification preferences. +* **Main Flow:** User goes to Settings -> Edits Profile -> Toggles notification settings for shift updates and billing alerts. + +--- + +# Use Case Diagram +```mermaid +flowchart TD + subgraph AppInitialization [App Initialization] + Start[Start App] --> CheckAuth{Check Auth Status} + CheckAuth -- Authenticated --> GoHome[Go to Main App] + CheckAuth -- Unauthenticated --> GetStarted[Go to Get Started] + end + + subgraph Authentication [Authentication] + GetStarted --> AuthChoice{Select Option} + AuthChoice -- Sign In --> SignIn[Sign In Screen] + AuthChoice -- Sign Up --> SignUp[Sign Up Screen] + + SignIn --> EnterCreds[Enter Credentials] + EnterCreds --> VerifySignIn{Verify} + VerifySignIn -- Success --> GoHome + VerifySignIn -- Failure --> SignInError[Show Error] + + SignUp --> EnterBusinessDetails[Enter Business Details] + EnterBusinessDetails --> CreateAccount[Create Account] + CreateAccount -- Success --> GoHome + end + + subgraph MainApp [Main Application Shell] + GoHome --> Shell[Scaffold with Nav Bar] + Shell --> TabNav{Tab Navigation} + + TabNav -- Index 0 --> Coverage[Coverage Tab] + TabNav -- Index 1 --> Billing[Billing Tab] + TabNav -- Index 2 --> Home[Home Tab] + TabNav -- Index 3 --> Orders[Orders/Shifts Tab] + TabNav -- Index 4 --> Reports[Reports Tab] + end + + subgraph HomeActions [Home Tab Actions] + Home --> CreateOrderAction{Create Order} + CreateOrderAction -- Rapid --> RapidOrder[Rapid Order Flow] + CreateOrderAction -- One-Time --> OneTimeOrder[One-Time Order Flow] + CreateOrderAction -- Recurring --> RecurringOrder[Recurring Order Flow] + CreateOrderAction -- Permanent --> PermanentOrder[Permanent Order Flow] + + Home --> QuickActions[Quick Actions Widget] + Home --> Settings[Go to Settings] + Home --> Hubs[Go to Client Hubs] + end + + subgraph OrderManagement [Order Management Flows] + RapidOrder --> SubmitRapid[Submit Rapid Order] + OneTimeOrder --> SubmitOneTime[Submit One-Time Order] + RecurringOrder --> SubmitRecurring[Submit Recurring Order] + PermanentOrder --> SubmitPermanent[Submit Permanent Order] + + SubmitRapid --> OrderSuccess[Order Success] + SubmitOneTime --> OrderSuccess + SubmitRecurring --> OrderSuccess + SubmitPermanent --> OrderSuccess + OrderSuccess --> Home + end + + subgraph ReportsAnalysis [Reports & Analytics] + Reports --> SelectReport{Select Report} + SelectReport -- Daily Ops --> DailyOps[Daily Ops Report] + SelectReport -- Spend --> SpendReport[Spend Report] + SelectReport -- Forecast --> ForecastReport[Forecast Report] + SelectReport -- Performance --> PerfReport[Performance Report] + SelectReport -- No-Show --> NoShowReport[No-Show Report] + SelectReport -- Coverage --> CovReport[Coverage Report] + end + + subgraph WorkforceMgmt [Workforce Management] + Orders --> ViewShifts[View Shifts List] + ViewShifts --> ShiftDetail[View Shift Detail] + ShiftDetail --> VerifyAttire[Verify Attire] + + Home --> ViewWorkers[View Workers List] + Home --> ViewTimesheets[View Timesheets] + ViewTimesheets --> ApproveTime[Approve Hours] + ViewTimesheets --> DisputeTime[Dispute Hours] + end + + subgraph SettingsFlow [Settings & Configuration] + Settings --> EditProfile[Edit Profile] + Settings --> ManageHubsLink[Manage Hubs] + Hubs --> AddHub[Add New Hub] + Hubs --> EditHub[Edit Existing Hub] + end + + %% Relationships across subgraphs + OrderSuccess -.-> Coverage + VerifySignIn -.-> Shell + CreateAccount -.-> Shell +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE/staff-mobile-application/use-case.md b/docs/ARCHITECTURE/staff-mobile-application/use-case.md new file mode 100644 index 00000000..23b920b8 --- /dev/null +++ b/docs/ARCHITECTURE/staff-mobile-application/use-case.md @@ -0,0 +1,208 @@ +# Staff Application: Use Case Overview + +This document details the primary business actions available within the **Staff Mobile Application**. It is organized according to the application's logical structure and navigation flow. + +--- + +## 1. Application Access & Authentication + +### 1.1 App Initialization +* **Actor:** Temporary Worker +* **Description:** The system checks if the user is logged in upon startup. +* **Main Flow:** + 1. Worker opens the app. + 2. System checks for a valid auth token. + 3. If valid, worker goes to **Home**. + 4. If invalid, worker goes to **Get Started**. + +### 1.2 Onboarding & Registration +* **Actor:** New Worker +* **Description:** Creating a new profile to join the Krow network. +* **Main Flow:** + 1. Worker enters phone number. + 2. System sends SMS OTP. + 3. Worker verifies OTP. + 4. System checks if profile exists. + 5. If new, worker completes **Profile Setup Wizard** (Personal Info -> Role/Experience -> Attire Sizes). + 6. Worker enters the Main App. + +--- + +## 2. Job Discovery (Home Tab) + +### 2.1 Browse & Filter Jobs +* **Actor:** Temporary Worker +* **Description:** Finding suitable work opportunities. +* **Main Flow:** + 1. Worker taps "View Available Jobs". + 2. Worker filters by Role (e.g., Server) or Distance. + 3. Worker selects a job card to view details (Pay, Location, Requirements). + +### 2.2 Claim Open Shift +* **Actor:** Temporary Worker +* **Description:** Committing to work a specific shift. +* **Main Flow:** + 1. From Job Details, worker taps "Claim Shift". + 2. System validates eligibility (Certificates, Conflicts). + 3. If eligible, shift is added to "My Schedule". + 4. If missing requirements, system prompts to **Upload Compliance Docs**. + +### 2.3 Set Availability +* **Actor:** Temporary Worker +* **Description:** Defining working hours to get better job matches. +* **Main Flow:** Worker taps "Set Availability" -> Selects dates/times -> Saves preferences. + +--- + +## 3. Shift Execution (Shifts & Clock In Tabs) + +### 3.1 View Schedule +* **Actor:** Temporary Worker +* **Description:** Reviewing upcoming commitments. +* **Main Flow:** Navigate to "My Shifts" tab -> View list of claimed shifts. + +### 3.2 GPS-Verified Clock In +* **Actor:** Temporary Worker +* **Description:** Starting a shift once on-site. +* **Main Flow:** + 1. Navigate to "Clock In" tab. + 2. System checks GPS location against job site coordinates. + 3. If **On Site**, "Swipe to Clock In" becomes active. + 4. Worker swipes to start the timer. + 5. If **Off Site**, system displays an error message. + +### 3.3 Submit Timesheet +* **Actor:** Temporary Worker +* **Description:** Completing a shift and submitting hours for payment. +* **Main Flow:** + 1. Worker swipes to "Clock Out". + 2. Worker confirms total hours and break times. + 3. Worker submits timesheet for client approval. + +--- + +## 4. Financial Management (Payments Tab) + +### 4.1 Track Earnings +* **Actor:** Temporary Worker +* **Description:** Monitoring financial progress. +* **Main Flow:** Navigate to "Payments" -> View "Pending Pay" (unpaid) and "Total Earned" (paid). + +### 4.2 Request Early Pay +* **Actor:** Temporary Worker +* **Description:** Accessing wages before the standard payday. +* **Main Flow:** + 1. Tap "Request Early Pay". + 2. Select amount to withdraw from available balance. + 3. Confirm transfer fee. + 4. Funds are transferred to the linked bank account. + +--- + +## 5. Profile & Compliance (Profile Tab) + +### 5.1 Manage Compliance Documents +* **Actor:** Temporary Worker +* **Description:** Keeping certifications up to date. +* **Main Flow:** Navigate to "Compliance Menu" -> "Upload Certificates" -> Take photo of ID/License -> Submit. + +### 5.2 Manage Tax Forms +* **Actor:** Temporary Worker +* **Description:** Submitting legal employment forms. +* **Main Flow:** Navigate to "Tax Forms" -> Complete W-4 or I-9 digitally -> Sign and Submit. + +### 5.3 Krow University Training +* **Actor:** Temporary Worker +* **Description:** Improving skills to unlock better jobs. +* **Main Flow:** Navigate to "Krow University" -> Select Module -> Watch Video/Take Quiz -> Earn Badge. + +### 5.4 Account Settings +* **Actor:** Temporary Worker +* **Description:** Managing personal data. +* **Main Flow:** Update Bank Details, View Benefits, or Access Support/FAQs. + +--- + +# Use Cases Diagram +```mermaid +flowchart TD + subgraph AppInitialization [App Initialization] + Start[Start App] --> CheckAuth{Check Auth Status} + CheckAuth -- Authenticated --> GoHome[Go to Main App] + CheckAuth -- Unauthenticated --> GetStarted[Go to Get Started] + end + + subgraph Authentication [Onboarding & Authentication] + GetStarted --> InputPhone[Enter Phone Number] + InputPhone --> VerifyOTP[Verify SMS Code] + VerifyOTP --> CheckProfile{Profile Complete?} + CheckProfile -- Yes --> GoHome + CheckProfile -- No --> SetupProfile[Profile Setup Wizard] + + SetupProfile --> Step1[Personal Info] + Step1 --> Step2[Role & Experience] + Step2 --> Step3[Attire Sizes] + Step3 --> GoHome + end + + subgraph MainApp [Main Application Shell] + GoHome --> Shell[Scaffold with Nav Bar] + Shell --> TabNav{Tab Navigation} + + TabNav -- Index 0 --> Shifts[My Shifts Tab] + TabNav -- Index 1 --> Payments[Payments Tab] + TabNav -- Index 2 --> Home[Home Tab] + TabNav -- Index 3 --> ClockIn[Clock In Tab] + TabNav -- Index 4 --> Profile[Profile Tab] + end + + subgraph HomeAndDiscovery [Job Discovery] + Home --> ViewOpenJobs[View Available Jobs] + ViewOpenJobs --> FilterJobs[Filter by Role/Distance] + ViewOpenJobs --> JobDetail[View Job Details] + JobDetail --> ClaimShift{Claim Shift} + ClaimShift -- Success --> ShiftSuccess[Shift Added to Schedule] + ClaimShift -- "Missing Req" --> PromptUpload[Prompt Compliance Upload] + + Home --> SetAvailability[Set Availability] + Home --> ViewUpcoming[View Upcoming Shifts] + end + + subgraph ShiftExecution [Shift Execution] + Shifts --> ViewSchedule[View My Schedule] + ClockIn --> CheckLocation{Verify GPS Location} + CheckLocation -- "On Site" --> SwipeIn[Swipe to Clock In] + CheckLocation -- "Off Site" --> LocationError[Show Location Error] + + SwipeIn --> ActiveShift[Shift In Progress] + ActiveShift --> SwipeOut[Swipe to Clock Out] + SwipeOut --> ConfirmHours[Confirm Hours & Breaks] + ConfirmHours --> SubmitTimesheet[Submit Timesheet] + end + + subgraph Financials [Earnings & Payments] + Payments --> ViewEarnings[View Pending Earnings] + Payments --> ViewHistory[View Payment History] + ViewEarnings --> EarlyPay{Request Early Pay?} + EarlyPay -- Yes --> SelectAmount[Select Amount] + SelectAmount --> ConfirmTransfer[Confirm Transfer] + end + + subgraph ProfileAndCompliance [Profile & Compliance] + Profile --> ComplianceMenu[Compliance Menu] + ComplianceMenu --> UploadDocs[Upload Certificates] + ComplianceMenu --> TaxForms["Manage Tax Forms (W-4/I-9)"] + + Profile --> KrowUniversity[Krow University] + KrowUniversity --> StartTraining[Start Training Module] + + Profile --> BankAccount[Manage Bank Details] + Profile --> Benefits[View Benefits] + Profile --> Support["Access Support/FAQs"] + end + + %% Relationships across subgraphs + SubmitTimesheet -.-> ViewEarnings + PromptUpload -.-> ComplianceMenu + ShiftSuccess -.-> ViewSchedule +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE/system-bible.md b/docs/ARCHITECTURE/system-bible.md new file mode 100644 index 00000000..bbf8e972 --- /dev/null +++ b/docs/ARCHITECTURE/system-bible.md @@ -0,0 +1,251 @@ +# The Krow Platform System Bible + +**Status:** Official / Living Document +**Version:** 1.0.0 + +--- + +## 1. Executive Summary + +### What the System Is +The **Krow Platform** is a multi-sided workforce management ecosystem that digitizes the entire lifecycle of temporary staffing. It replaces fragmented, manual processes (phone calls, spreadsheets, paper timesheets) with a unified digital infrastructure connecting businesses ("Clients") directly with temporary workers ("Staff"). + +### Why It Exists +The temporary staffing industry suffers from friction, lack of transparency, and delayed payments. Businesses struggle to find reliable staff quickly, while workers face uncertain schedules and slow wage access. Krow exists to remove this friction, ensuring shifts are filled instantly, work is verified accurately, and payments are processed swiftly. + +### Who It Serves +1. **Clients (Businesses):** Venue managers and owners who need on-demand or scheduled staff. +2. **Staff (Workers):** Individuals seeking flexible, temporary employment opportunities. +3. **Admins (Operations):** Internal teams managing the marketplace, compliance, and financial flows. + +### High-Level Value Proposition +Krow transforms labor from a manual logistical headache into a streamlined digital asset. For clients, it provides "staff on tap" with verified compliance. For workers, it offers "freedom and instant pay." For the platform operators, it delivers data-driven oversight of a complex marketplace. + +--- + +## 2. System Vision & Product Principles + +### Core Goals +1. **Immediacy:** Reduce the time-to-fill for urgent shifts from hours to minutes. +2. **Accuracy:** Eliminate payroll disputes through GPS-verified digital timesheets. +3. **Compliance:** Automate the enforcement of legal and safety requirements (attire, certifications). + +### Problems It Intentionally Solves +* **The "No-Show" Epidemic:** By creating a transparent marketplace with reliability ratings. +* **Payroll Latency:** By enabling "Early Pay" features rooted in verified digital time cards. +* **Administrative Bloat:** By automating invoice generation and worker onboarding. + +### Problems It Intentionally Does NOT Solve +* **Full-Time Recruitment:** This system is optimized for shift-based, temporary labor, not permanent headhunting. +* **Gig Economy "Tasking":** It focuses on professional hospitality and event roles, not general unskilled errands. + +### Guiding Product Principles +* **Mobile-First for Operations:** If a task happens on a job site (clocking in, checking coverage), it *must* be possible on a phone. +* **Data as the Truth:** We do not rely on verbal confirmations. If it isn't in the system (GPS stamp, digital signature), it didn't happen. +* **Separation of Concerns:** Clients manage demand; Staff manage supply; Admin manages the rules. These roles never blur. + +--- + +## 3. Ecosystem Overview + +The ecosystem comprises three distinct applications, each serving a specific user persona but operating on a shared reality. + +### 1. Client Mobile Application (The "Requester") +* **Platform:** Flutter (Mobile) +* **Responsibility:** Demand generation. Allows businesses to post orders, track arriving staff in real-time, and approve work hours. +* **Concept:** The "Remote Control" for the venue's staffing operations. + +### 2. Staff Mobile Application (The "Worker") +* **Platform:** Flutter (Mobile) +* **Responsibility:** Supply fulfillment. Empowering workers to find jobs, manage their schedule, verify their presence (Clock In), and access earnings. +* **Concept:** The worker's "Digital Agency" in their pocket. + +### 3. Krow Web Application (The "HQ") +* **Platform:** React (Web) +* **Responsibility:** Ecosystem governance. The command center for high-level analytics, complex financial operations (invoicing/payouts), vendor management, and system administration. +* **Concept:** The "Mission Control" for the business backend. + +--- + +## 4. System Architecture Overview + +The Krow Platform follows a **Service-Oriented Architecture (SOA)** where multiple front-end clients interface with a shared, monolithic logical backend (exposed via API Gateway). + +### Architectural Style +* **Centralized State:** A single backend database serves as the source of truth for all apps. +* **Role-Based Access:** The API exposes different endpoints and data views depending on the authenticated user's role (Client vs. Staff). +* **Event-Driven Flows:** Key actions (e.g., "Shift Posted") trigger downstream effects (e.g., "Push Notification Sent") across the ecosystem. + +### System Boundaries +The "System" encompasses the three front-end apps and the shared backend services. External boundaries are drawn at: +* **Payment Gateways:** We initiate transfers, but the actual money movement is external. +* **Maps/Geolocation:** We consume location data but do not maintain mapping infrastructure. +* **SMS/Identity:** We offload OTP delivery to specialized providers. + +### Trust Boundaries +* **Mobile Apps are Untrusted:** We assume any data coming from a client device (GPS coordinates, timestamps) could be manipulated and must be validated server-side. +* **Web App is Semi-Trusted:** Admin actions are logged for audit but are assumed to be authorized operations. + +```mermaid +flowchart TD + subgraph Clients [Client Layer] + CMA[Client Mobile App] + SMA[Staff Mobile App] + WEB[Web Admin Portal] + end + + subgraph API [Interface Layer] + Gateway[API Gateway] + end + + subgraph Services [Core Service Layer] + Auth[Identity Service] + Ops[Operations Service] + Finance[Financial Service] + end + + subgraph Data [Data Layer] + DB[(Central Database)] + end + + CMA --> Gateway + SMA --> Gateway + WEB --> Gateway + Gateway --> Services + Services --> DB +``` + +--- + +## 5. Application Responsibility Matrix + +| Feature Domain | Client App (Mobile) | Staff App (Mobile) | Web App (Admin/Ops) | +| :--- | :--- | :--- | :--- | +| **User Onboarding** | Register Business | Register Worker | Onboard Vendors | +| **Shift Management** | **Create** & Monitor | **Claim** & Execute | **Oversee** & Resolve | +| **Time Tracking** | Approve / Dispute | Clock In / Out | Audit Logs | +| **Finance** | Pay Invoices | Request Payout | Generate Bills | +| **Compliance** | Verify Attire | Upload Docs | Verify Docs | +| **Analytics** | View My Venue Stats | View My Earnings | Global Market Analysis | + +### Critical Rules +* **Client App MUST NOT** access worker financial data (bank details, total platform earnings). +* **Staff App MUST NOT** see client billing rates or internal venue notes. +* **Web App MUST** be the only place where global system configurations (e.g., platform fees) are changed. + +--- + +## 6. Use Cases + +The following are the **official system use cases**. Any feature request not mapping to one of these must be scrutinized. + +### A. Staffing Operations +1. **Post Urgent Shift (Client):** + * *Pre:* Valid client account. + * *Flow:* Select Role -> Set Qty -> Post. + * *Post:* Notification sent to eligible workers. +2. **Claim Shift (Staff):** + * *Pre:* Worker meets compliance reqs. + * *Flow:* View Job -> Accept. + * *Post:* Shift is locked; Client notified. +3. **Execute Shift (Staff):** + * *Pre:* On-site GPS verification. + * *Flow:* Clock In -> Work -> Clock Out. + * *Validation:* Location check passes. +4. **Approve Timesheet (Client):** + * *Pre:* Shift completed. + * *Flow:* Review Hours -> Approve. + * *Post:* Payment scheduled. + +### B. Financial Operations +5. **Process Billing (Web/Admin):** + * *Flow:* Aggregate approved hours -> Generate Invoice -> Charge Client. +6. **Request Early Pay (Staff):** + * *Pre:* Accrued unpaid earnings. + * *Flow:* Select Amount -> Confirm -> Transfer. + +### C. Governance +7. **Verify Compliance (Web/Admin):** + * *Flow:* Review uploaded ID -> Mark as Verified. + * *Post:* Worker eligible for shifts. +8. **Strategic Analysis (Web/Client):** + * *Flow:* View Savings Engine -> Adopt Recommendation. + +--- + +## 7. Cross-Application Interaction Rules + +### Communication Patterns +* **Indirect Communication:** Apps NEVER speak peer-to-peer. + * *Correct:* Client App posts order -> Backend -> Staff App receives notification. + * *Forbidden:* Client App sends data directly to Staff App via Bluetooth/LAN. +* **Push Notifications:** Used as the primary signal to "wake up" an app and fetch fresh data from the server. + +### Dependency Direction +* **Downstream Dependency:** The Mobile Apps depend on the Web App's configuration (e.g., if Admin adds a new "Role Type" on Web, it appears on Mobile). +* **Upstream Data Flow:** Operational data flows *up* from Mobile (Clock-ins) to Web (Reporting). + +### Anti-Patterns to Avoid +* **"Split Brain" Logic:** Business logic (e.g., "How is overtime calculated?") must live in the Backend, NOT duplicated in the mobile apps. +* **Local-Only State:** Critical data (shift status) must never exist only on a user's device. It must be synced immediately. + +--- + +## 8. Data Ownership & Source of Truth + +| Data Domain | Source of Truth | Write Access | Read Access | +| :--- | :--- | :--- | :--- | +| **User Identity** | Auth Service | User (Self), Admin | System-wide | +| **Shift Status** | Order Service | Client (Create), Staff (Update status) | All (Context dependent) | +| **Time Cards** | Database | Staff (Create), Client (Approve) | Admin, Payroll | +| **Compliance Docs** | Worker Profile | Staff (Upload), Admin (Verify) | Client (Status only) | +| **Platform Rates** | System Config | Admin | Read-only | + +### Consistency Principle +**"The Server is Right."** If a mobile device displays a shift as "Open" but the server says "Filled," the device is wrong and must refresh. We prioritize data integrity over offline availability for critical transaction states. + +--- + +## 9. Security & Access Model + +### User Roles +1. **Super Admin:** Full system access. +2. **Client Manager:** Access to own venue data only. +3. **Worker:** Access to own schedule and public job board only. + +### Authentication Philosophy +* **Zero Trust:** Every API request must carry a valid, unexpired token. +* **Session Management:** Mobile sessions are persistent (long-lived tokens) for usability; Web sessions (Admin) are shorter for security. + +### Authorization Boundaries +* **Horizontal Separation:** Client A cannot see Client B's orders. Worker A cannot see Worker B's pay. +* **Vertical Separation:** Staff cannot access Admin APIs. + +--- + +## 10. Non-Negotiables & Guardrails + +1. **No GPS, No Pay:** A clock-in event *must* have valid geolocation data attached. No exceptions. +2. **Compliance First:** A worker cannot claim a shift if their required documents (e.g., Food Handler Card) are expired. The system must block this at the API level. +3. **Immutable Audit Trail:** Once a timesheet is approved and paid, it cannot be deleted or modified, only reversed via a new corrective transaction. +4. **One Account Per Person:** Strict identity checks to prevent duplicate worker profiles. + +--- + +## 11. Known Constraints & Assumptions + +* **Connectivity:** The system assumes a reliable internet connection for critical actions (Claiming, Clocking In). Offline mode is limited to read-only views of cached schedules. +* **Device Capability:** We assume worker devices have functional GPS and Camera hardware. +* **Payment Timing:** "Instant" pay is subject to external banking network delays (ACH/RTP) and is not truly real-time in all cases. + +--- + +## 12. Glossary + +* **Shift:** A single unit of work with a start time, end time, and role. +* **Order:** A request from a client containing one or more shifts. +* **Clock-In:** The digital timestamp marking the start of work, verified by GPS. +* **Rapid Order:** A specific order type designed for immediate (<24h) fulfillment. +* **Early Pay:** A financial feature allowing workers to withdraw accrued wages before the standard pay period ends. +* **Hub:** A specific physical location or venue belonging to a Client. +* **Compliance:** The state of having all necessary legal and safety documents verified. diff --git a/docs/ARCHITECTURE/web-application/use-case.md b/docs/ARCHITECTURE/web-application/use-case.md new file mode 100644 index 00000000..e36a1ac6 --- /dev/null +++ b/docs/ARCHITECTURE/web-application/use-case.md @@ -0,0 +1,639 @@ +# Web Application: Use Case Overview + +This document details the primary business actions and user flows within the **Krow Web Application**. It is organized according to the logical workflows for each primary user role as defined in the system's architecture. + +--- + +## 1. Access & Authentication (Common) + +### 1.1 Web Portal Login +* **Actor:** All Users (Admin, Client, Vendor) +* **Description:** Secure entry into the management console. +* **Main Flow:** + 1. User enters email and password on the login screen. + 2. System verifies credentials against authentication service. + 3. System determines user role (Admin, Client, or Vendor). + 4. User is directed to their specific role-based dashboard with customizable widgets. + 5. System loads user-specific dashboard layout preferences. + +--- + +## 2. Admin Workflows (Operations Manager) + +### 2.1 Global Operational Oversight +* **Actor:** Admin +* **Description:** Monitoring the pulse of the entire platform through a customizable dashboard. +* **Main Flow:** + 1. User accesses Admin Dashboard with global metrics. + 2. Views fill rate, total spend, performance score, and active events. + 3. Monitors today's orders with status indicators (RAPID, Fully Staffed, Partial Staffed). + 4. Reviews action items prioritized by urgency (critical, high, medium). + 5. Accesses ecosystem visualization showing connections between Buyers, Enterprises, Sectors, Partners, and Vendors. + 6. Customizes dashboard widget layout via drag-and-drop. + +### 2.2 Vendor & Partner Management +* **Actor:** Admin +* **Description:** Managing the vendor network and partnerships. +* **Main Flow:** + 1. User navigates to Vendor Marketplace. + 2. Reviews vendor approval status and performance metrics. + 3. Sets vendor tier levels (Approved Vendor, Gold Vendor). + 4. Monitors vendor CSAT scores and compliance rates. + 5. Views vendor rate books and service rates. + +### 2.3 Order & Schedule Management +* **Actor:** Admin +* **Description:** Overseeing all orders across the platform. +* **Main Flow:** + 1. User views all orders with filtering by status (All, Upcoming, Active, Past, Conflicts). + 2. Reviews order details including business, hub, date/time, assigned staff. + 3. Monitors assignment status (Requested vs Assigned counts). + 4. Detects and resolves scheduling conflicts. + 5. Accesses schedule view for visual timeline. + +### 2.4 Workforce Management +* **Actor:** Admin +* **Description:** Managing platform-wide workforce. +* **Main Flow:** + 1. User navigates to Staff Directory. + 2. Views staff with filters (position, department, hub, profile type). + 3. Monitors compliance status (background checks, certifications). + 4. Reviews staff performance metrics (rating, reliability score, shift coverage). + 5. Manages onboarding workflows for new staff. + +### 2.5 Analytics & Reporting +* **Actor:** Admin +* **Description:** Generating insights through reports and activity logs. +* **Main Flow:** + 1. User accesses Reports Dashboard. + 2. Selects report type (Staffing Cost, Staff Performance, Operational Efficiency, Client Trends). + 3. Configures report parameters and filters. + 4. Views report insights with AI-generated recommendations. + 5. Exports reports in multiple formats (PDF, Excel, CSV). + 6. Reviews Activity Log for audit trail. + +--- + +## 3. Client Executive Workflows + +### 3.1 Dashboard Overview +* **Actor:** Client Executive +* **Description:** Personalized dashboard for order and labor management. +* **Main Flow:** + 1. User opens Client Dashboard with customizable widgets. + 2. Views action items (overdue invoices, unfilled orders, rapid requests). + 3. Monitors key metrics (Today's Count, In Progress, Needs Attention). + 4. Reviews labor summary with cost breakdown by position. + 5. Analyzes sales analytics via pie charts. + +### 3.2 Order Management +* **Actor:** Client Executive / Operations Manager +* **Description:** Creating and managing staffing requests. +* **Main Flow:** + 1. User clicks "Order Now" or "RAPID Order" for urgent requests. + 2. Selects business, hub, and event details. + 3. Defines shifts with roles, counts, start/end times, and rates. + 4. Chooses order type (one-time, rapid, recurring, permanent). + 5. Enables conflict detection to prevent scheduling issues. + 6. Reviews detected conflicts before submission. + 7. Submits order to preferred vendor or marketplace. + +### 3.3 Vendor Discovery & Selection +* **Actor:** Client Executive +* **Description:** Finding and managing vendor relationships. +* **Main Flow:** + 1. User navigates to Vendor Marketplace. + 2. Searches and filters vendors by region, category, rating, price. + 3. Views vendor profiles with metrics (staff count, rating, fill rate, response time). + 4. Expands vendor cards to view rate books by category. + 5. Sets preferred vendor for automatic order routing. + 6. Configures vendor preferences (locked vendors for optimization). + 7. Contacts vendors via integrated messaging. + +### 3.4 Savings Engine (Strategic Insights) +* **Actor:** Client Executive +* **Description:** Using AI to optimize labor spend and vendor mix. +* **Main Flow:** + 1. User opens Savings Engine. + 2. Reviews overview cards showing total spend, potential savings, fill rate. + 3. Selects analysis timeframe (7 days, 30 days, Quarter, Year). + 4. Navigates tabs for different insights: + - **Overview**: Dynamic dashboard with savings opportunities + - **Budget**: Budget utilization tracker + - **Strategies**: Smart operation strategies with AI recommendations + - **Predictions**: Cost forecasts and trend analysis + - **Vendors**: Vendor performance comparison + 5. Views actionable strategies (vendor consolidation, rate optimization). + 6. Exports analysis report. + +### 3.5 Finance & Invoicing +* **Actor:** Client Executive / Finance Admin +* **Description:** Managing invoices and payments. +* **Main Flow:** + 1. User views invoice list filtered by status (Open, Overdue, Paid, Disputed). + 2. Opens invoice detail to review line items by role and staff. + 3. Views from/to company information and payment terms. + 4. Downloads invoice in PDF or Excel format. + 5. Processes payment or disputes invoice with reason. + 6. Tracks payment history. + +### 3.6 Communication & Support +* **Actor:** Client Executive +* **Description:** Engaging with vendors and getting help. +* **Main Flow:** + 1. User accesses Message Center for conversations. + 2. Initiates conversation with vendors or admins. + 3. Views conversation threads grouped by type (client-vendor, client-admin). + 4. Accesses Tutorials for platform guidance. + 5. Submits support tickets via Support Center. + +--- + +## 4. Vendor Workflows (Staffing Agency) + +### 4.1 Vendor Dashboard +* **Actor:** Vendor Manager +* **Description:** Comprehensive view of operations and performance. +* **Main Flow:** + 1. User accesses Vendor Dashboard with customizable widgets. + 2. Views KPI cards (Orders Today, In Progress, RAPID, Staff Assigned). + 3. Monitors action items (urgent unfilled orders, expiring certifications, invoices to submit). + 4. Reviews recent orders table with assignment status. + 5. Accesses revenue carousel showing monthly revenue, total revenue, active orders. + 6. Views top clients by revenue and order count. + 7. Reviews client loyalty status (Champion, Loyal, At Risk). + 8. Monitors top performer staff by rating. + +### 4.2 Order Fulfillment +* **Actor:** Vendor Manager +* **Description:** Fulfilling client staffing requests. +* **Main Flow:** + 1. User views incoming orders via "Orders" section. + 2. Filters orders by tab (All, Conflicts, Upcoming, Active, Past). + 3. Reviews order details (business, hub, event, date/time, roles). + 4. Identifies RAPID orders (< 24 hours) needing immediate attention. + 5. Clicks "Assign Staff" to open Smart Assign Modal. + 6. Selects optimal staff based on skills, availability, and proximity. + 7. Confirms assignments and updates order status. + 8. Reviews conflict alerts for staff/venue overlaps. + +### 4.3 Workforce Roster Management +* **Actor:** Vendor Manager +* **Description:** Managing agency's worker pool. +* **Main Flow:** + 1. User navigates to Staff Directory. + 2. Views staff with filtering options (profile type, position, department, hub). + 3. Toggles between grid and list view. + 4. Adds new staff via "Add Staff" button. + 5. Fills staff profile form (personal info, position, department, hub, contact). + 6. Edits existing staff profiles. + 7. Monitors staff metrics (rating, reliability score, shift coverage, cancellations). + 8. Reviews compliance status (background checks, certifications). + +### 4.4 Staff Onboarding +* **Actor:** Vendor Manager +* **Description:** Streamlined multi-step onboarding for new workers. +* **Main Flow:** + 1. User navigates to "Onboard Staff" section. + 2. Completes profile setup step (name, email, position, department). + 3. Uploads required documents (ID, certifications, licenses). + 4. Assigns training modules. + 5. Reviews completion status. + 6. Activates staff member upon completion. + +### 4.5 Compliance Management +* **Actor:** Vendor Manager +* **Description:** Maintaining workforce compliance standards. +* **Main Flow:** + 1. User accesses Compliance Dashboard. + 2. Views compliance metrics (background check status, certification expiry). + 3. Filters staff needing attention. + 4. Updates compliance documents in Document Vault. + 5. Tracks certification renewal deadlines. + +### 4.6 Schedule & Availability +* **Actor:** Vendor Manager +* **Description:** Managing staff availability and schedules. +* **Main Flow:** + 1. User navigates to Staff Availability. + 2. Views calendar-based availability grid. + 3. Updates staff availability preferences. + 4. Accesses Schedule view for visual timeline of assignments. + 5. Identifies gaps and conflicts. + +### 4.7 Client Relationship Management +* **Actor:** Vendor Manager +* **Description:** Managing client accounts and preferences. +* **Main Flow:** + 1. User navigates to Clients section. + 2. Views client list with business details. + 3. Adds new client accounts. + 4. Edits client information (contact, address, hubs, departments). + 5. Configures client preferences (favorite staff, blocked staff). + 6. Sets ERP integration details (vendor ID, cost center, EDI format). + +### 4.8 Rate Management +* **Actor:** Vendor Manager +* **Description:** Managing service rates and pricing. +* **Main Flow:** + 1. User accesses Service Rates section. + 2. Views rate cards by client and role. + 3. Creates new rate entries (role, client rate, employee wage). + 4. Configures markup percentage and vendor fee. + 5. Sets approved cap rates. + 6. Activates/deactivates rates. + +### 4.9 Vendor Finance & Invoicing +* **Actor:** Vendor Manager +* **Description:** Managing revenue and submitting invoices. +* **Main Flow:** + 1. User views invoice list for completed orders. + 2. Auto-generates invoices from completed events. + 3. Reviews invoice details with staff entries and line items. + 4. Edits invoice before submission if needed. + 5. Submits invoice to client. + 6. Tracks invoice status (Draft, Open, Confirmed, Paid). + 7. Downloads invoice for records. + +### 4.10 Performance Analytics +* **Actor:** Vendor Manager +* **Description:** Monitoring vendor performance metrics. +* **Main Flow:** + 1. User accesses Performance section. + 2. Reviews fill rate, on-time performance, client satisfaction. + 3. Views staff performance leaderboard. + 4. Analyzes revenue trends by client and timeframe. + +### 4.11 Savings Engine (Growth Opportunities) +* **Actor:** Vendor Manager +* **Description:** Identifying growth and optimization opportunities. +* **Main Flow:** + 1. User opens Savings Engine with vendor-specific tabs. + 2. Reviews performance metrics and benchmarks. + 3. Identifies opportunities to improve ratings and win more business. + 4. Views workforce utilization statistics. + 5. Analyzes growth forecasts. + +--- + +## 5. Shared Functional Modules + +### 5.1 Order Details & History +* **Actor:** All Roles +* **Description:** Accessing granular data for any specific staffing request. +* **Main Flow:** + 1. User clicks any order ID from lists or dashboards. + 2. System displays comprehensive order information: + - Event details (name, business, hub, date, time) + - Shift configuration with roles, counts, and rates + - Assigned staff with profiles + - Status history and audit trail + - Detected conflicts (if any) + - Invoice linkage (if completed) + 3. User can edit order (if permissions allow). + 4. User can assign/reassign staff. + 5. User can view related invoices. + +### 5.2 Invoice Detail View +* **Actor:** Admin, Client, Vendor +* **Description:** Reviewing the breakdown of costs for a billing period. +* **Main Flow:** + 1. User opens an invoice from the invoice list. + 2. System displays invoice header (invoice number, dates, status, parties). + 3. Views detailed breakdown: + - Roles section with staff entries per role + - Hours worked (regular, overtime, double-time) + - Bill rates and totals per role + - Additional charges + - Subtotal and grand total + 4. Reviews payment terms and PO reference. + 5. Downloads invoice in PDF or Excel. + 6. Copies invoice data to clipboard. + 7. Sends invoice via email (vendor role). + 8. Approves or disputes invoice (client role). + +### 5.3 Task Board +* **Actor:** All Roles +* **Description:** Collaborative task management across teams. +* **Main Flow:** + 1. User accesses Task Board. + 2. Views tasks in columns by status (Pending, In Progress, On Hold, Completed). + 3. Drags tasks between columns to update status. + 4. Creates new tasks with details (name, description, priority, due date). + 5. Assigns tasks to team members. + 6. Adds comments and attachments to tasks. + 7. Filters tasks by department, priority, or assignee. + +### 5.4 Message Center +* **Actor:** All Roles +* **Description:** Cross-platform communication hub. +* **Main Flow:** + 1. User accesses Message Center. + 2. Views conversation list with unread counts. + 3. Filters by conversation type (client-vendor, client-admin, internal). + 4. Opens conversation thread. + 5. Sends messages with attachments. + 6. Views system-generated messages for automated events. + 7. Archives completed conversations. + +### 5.5 Reports & Analytics +* **Actor:** All Roles (with role-specific access) +* **Description:** Data-driven insights and custom reporting. +* **Main Flow:** + 1. User accesses Reports Dashboard. + 2. Selects from report types: + - Staffing Cost Report + - Staff Performance Report + - Operational Efficiency Report + - Client Trends Report + - Custom Report Builder + 3. Configures report parameters (date range, filters, grouping). + 4. Views AI-generated insights banner with key findings. + 5. Exports report in preferred format. + 6. Schedules recurring reports for automated delivery. + 7. Saves report templates for reuse. + +### 5.6 Teams Management +* **Actor:** Admin, Client, Vendor +* **Description:** Creating and managing staff teams. +* **Main Flow:** + 1. User navigates to Teams section. + 2. Views team list with member counts. + 3. Creates new team with name and description. + 4. Adds team members from staff directory. + 5. Views team detail page with member profiles. + 6. Assigns teams to orders as groups. + +### 5.7 Staff Conflict Detection +* **Actor:** Admin, Vendor +* **Description:** Automated detection of scheduling conflicts. +* **Main Flow:** + 1. System automatically detects conflicts when creating/editing orders: + - **Staff Overlap**: Same staff assigned to overlapping shifts + - **Venue Overlap**: Same venue booked for overlapping times + - **Time Buffer**: Insufficient travel time between assignments + 2. System assigns severity level (Critical, High, Medium, Low). + 3. Displays conflict alerts with details (conflicting event, staff, location). + 4. User resolves conflicts before finalizing order. + 5. System tracks conflict resolution in audit log. + +### 5.8 Dashboard Customization +* **Actor:** All Roles +* **Description:** Personalizing dashboard layouts. +* **Main Flow:** + 1. User clicks "Customize Dashboard" button. + 2. Enters customization mode with drag-and-drop interface. + 3. Reorders widgets by dragging. + 4. Hides/shows widgets using visibility controls. + 5. Previews changes in real-time. + 6. Saves layout preferences to user profile. + 7. Resets to default layout if desired. + +--- + +## 6. Advanced Features + +### 6.1 Smart Assignment Engine (Vendor) +* **Actor:** Vendor Manager +* **Description:** AI-powered staff assignment optimization. +* **Main Flow:** + 1. User clicks "Smart Assign" on an order. + 2. System analyzes requirements (skills, location, time, availability). + 3. Engine scores available staff based on: + - Skill match + - Proximity to venue + - Past performance + - Availability + - Client preferences + 4. Presents ranked staff recommendations. + 5. User reviews suggestions and confirms assignments. + +### 6.2 Auto-Invoice Generation +* **Actor:** Vendor Manager +* **Description:** Automated invoice creation from completed orders. +* **Main Flow:** + 1. When order status changes to "Completed", system triggers auto-invoice. + 2. System aggregates staff entries, hours, and rates. + 3. Generates invoice line items by role. + 4. Calculates totals (regular, overtime, double-time). + 5. Applies additional charges if configured. + 6. Creates draft invoice for vendor review. + 7. Vendor reviews and submits to client. + +### 6.3 Vendor Preferences & Optimization (Client) +* **Actor:** Client Executive +* **Description:** Configuring vendor routing and procurement strategies. +* **Main Flow:** + 1. User accesses Client Vendor Preferences panel. + 2. Sets preferred vendor for automatic order routing. + 3. Configures locked vendors (never used for optimization). + 4. Enables/disables procurement optimization. + 5. System respects preferences when suggesting vendors in Savings Engine. + +### 6.4 Contract Conversion & Tier Optimization +* **Actor:** Admin, Client (via Savings Engine) +* **Description:** Analyzing opportunities to move spend to preferred vendors. +* **Main Flow:** + 1. User accesses "Conversion Map" tab in Savings Engine. + 2. Views non-contracted spend by vendor. + 3. System identifies conversion opportunities to approved/gold vendors. + 4. Reviews potential savings from rate arbitrage. + 5. Approves conversion strategy. + 6. System routes future orders accordingly. + +### 6.5 Predictive Savings Model +* **Actor:** Admin, Client +* **Description:** Forecasting cost savings through AI analysis. +* **Main Flow:** + 1. User accesses "Predictions" tab in Savings Engine. + 2. System analyzes historical spend, rates, and vendor performance. + 3. Generates forecasts for 7 days, 30 days, quarter, year. + 4. Identifies rate optimization opportunities. + 5. Recommends vendor consolidation strategies. + 6. Shows projected ROI for each strategy. + +--- + +# Use Case Diagram + +```mermaid +flowchart TD + subgraph AccessControl [Access & Authentication] + Start[Start Web Portal] --> CheckSession{Check Session} + CheckSession -- Valid --> CheckRole{Check User Role} + CheckSession -- Invalid --> Login[Login Screen] + Login --> EnterCreds[Enter Credentials] + EnterCreds --> Verify{Verify} + Verify -- Success --> CheckRole + Verify -- Failure --> Error[Show Error] + + CheckRole -- Admin --> AdminDash[Admin Dashboard] + CheckRole -- Client --> ClientDash[Client Dashboard] + CheckRole -- Vendor --> VendorDash[Vendor Dashboard] + end + + subgraph AdminWorkflows [Admin Workflows] + AdminDash --> GlobalOversight[Global Oversight] + GlobalOversight --> EcosystemWheel[Ecosystem Wheel] + GlobalOversight --> ViewAllOrders[View All Orders] + GlobalOversight --> ActionItems[Action Items] + + AdminDash --> VendorMgmt[Vendor Management] + VendorMgmt --> ApproveVendors[Approve Vendors] + VendorMgmt --> SetTiers[Set Vendor Tiers] + + AdminDash --> WorkforceMgmt[Workforce Management] + WorkforceMgmt --> StaffDirectory[Staff Directory] + WorkforceMgmt --> Compliance[Compliance Dashboard] + + AdminDash --> AnalyticsReports[Analytics & Reports] + AnalyticsReports --> ReportsDashboard[Reports Dashboard] + AnalyticsReports --> ActivityLog[Activity Log] + end + + subgraph ClientWorkflows [Client Executive Workflows] + ClientDash --> ClientActionItems[Action Items] + ClientActionItems --> ReviewAlerts[Review Alerts] + + ClientDash --> OrderMgmt[Order Management] + OrderMgmt --> CreateOrder[Create Order] + CreateOrder --> DefineShifts[Define Shifts & Roles] + DefineShifts --> ConflictDetection[Conflict Detection] + ConflictDetection --> SubmitOrder[Submit Order] + + OrderMgmt --> ViewMyOrders[View My Orders] + ViewMyOrders --> OrderDetail[Order Detail] + + ClientDash --> VendorDiscovery[Vendor Discovery] + VendorDiscovery --> BrowseMarketplace[Browse Marketplace] + BrowseMarketplace --> SetPreferred[Set Preferred Vendor] + BrowseMarketplace --> ContactVendor[Contact Vendor] + + ClientDash --> SavingsEngine[Savings Engine] + SavingsEngine --> AnalyzeSpend[Analyze Spend] + AnalyzeSpend --> ViewStrategies[View Strategies] + ViewStrategies --> ApproveStrategy[Approve Strategy] + SavingsEngine --> PredictiveSavings[Predictive Savings] + SavingsEngine --> ConversionMap[Conversion Map] + + ClientDash --> ClientFinance[Finance & Invoicing] + ClientFinance --> ViewInvoices[View Invoices] + ViewInvoices --> InvoiceDetail[Invoice Detail] + InvoiceDetail --> PayInvoice[Pay Invoice] + InvoiceDetail --> DisputeInvoice[Dispute Invoice] + + ClientDash --> Communication[Communication] + Communication --> MessageCenter[Message Center] + Communication --> SupportCenter[Support Center] + end + + subgraph VendorWorkflows [Vendor Workflows] + VendorDash --> VendorKPIs[KPI Dashboard] + VendorKPIs --> RevenueStats[Revenue Stats] + VendorKPIs --> TopClients[Top Clients] + VendorKPIs --> TopPerformers[Top Performers] + + VendorDash --> OrderFulfillment[Order Fulfillment] + OrderFulfillment --> ViewOrders[View Orders] + ViewOrders --> FilterOrders[Filter Orders] + FilterOrders --> AssignStaff[Smart Assign Staff] + AssignStaff --> ResolveConflicts[Resolve Conflicts] + + VendorDash --> RosterMgmt[Roster Management] + RosterMgmt --> StaffDir[Staff Directory] + StaffDir --> AddStaff[Add Staff] + StaffDir --> EditStaff[Edit Staff] + StaffDir --> ViewMetrics[View Staff Metrics] + + RosterMgmt --> OnboardStaff[Onboard Staff] + OnboardStaff --> ProfileSetup[Profile Setup] + ProfileSetup --> UploadDocs[Upload Documents] + UploadDocs --> AssignTraining[Assign Training] + AssignTraining --> ActivateStaff[Activate Staff] + + VendorDash --> ComplianceMgmt[Compliance Management] + ComplianceMgmt --> ComplianceDash[Compliance Dashboard] + ComplianceDash --> DocumentVault[Document Vault] + ComplianceDash --> CertTracking[Certification Tracking] + + VendorDash --> ScheduleAvail[Schedule & Availability] + ScheduleAvail --> StaffAvailability[Staff Availability] + ScheduleAvail --> ScheduleView[Schedule View] + + VendorDash --> ClientMgmt[Client Management] + ClientMgmt --> ManageClients[Manage Clients] + ManageClients --> ClientPrefs[Client Preferences] + + VendorDash --> RateMgmt[Rate Management] + RateMgmt --> ServiceRates[Service Rates] + ServiceRates --> RateCards[Rate Cards] + + VendorDash --> VendorFinance[Finance] + VendorFinance --> AutoInvoice[Auto-Generate Invoice] + VendorFinance --> SubmitInvoice[Submit Invoice] + VendorFinance --> TrackPayments[Track Payments] + + VendorDash --> VendorPerformance[Performance Analytics] + VendorPerformance --> FillRate[Fill Rate] + VendorPerformance --> CSAT[Client Satisfaction] + VendorPerformance --> RevenueAnalysis[Revenue Analysis] + end + + subgraph SharedModules [Shared Functional Modules] + TaskBoard[Task Board] -.-> Tasks[Manage Tasks] + Tasks -.-> DragDrop[Drag & Drop Status] + + MessageCenter -.-> Conversations[Conversations] + Conversations -.-> SendMessage[Send Message] + + ReportsDashboard -.-> ReportTypes[Report Types] + ReportTypes -.-> CustomBuilder[Custom Report Builder] + ReportTypes -.-> ScheduledReports[Scheduled Reports] + ReportTypes -.-> ExportReport[Export Report] + + TeamsModule[Teams] -.-> CreateTeam[Create Team] + CreateTeam -.-> AddMembers[Add Members] + + DashboardCustom[Dashboard Customization] -.-> DragWidgets[Drag Widgets] + DragWidgets -.-> HideShow[Hide/Show Widgets] + HideShow -.-> SaveLayout[Save Layout] + end +``` + +--- + +## Summary of Key Enhancements + +**Compared to the original document, this updated version includes:** + +1. **Detailed Dashboard Workflows**: Comprehensive descriptions of customizable dashboards for each role with specific widgets and metrics. + +2. **Advanced Order Management**: Multi-step order creation with shift configuration, conflict detection, and order type options (one-time, rapid, recurring, permanent). + +3. **Smart Assignment**: AI-powered staff assignment engine for vendors to optimize worker selection. + +4. **Savings Engine**: Detailed AI-driven cost optimization workflows with predictive modeling, vendor conversion strategies, and budget tracking. + +5. **Vendor Marketplace**: Complete vendor discovery and selection process with filtering, rate comparison, and preference settings. + +6. **Enhanced Finance**: Auto-invoice generation, detailed invoice views, export capabilities, and dispute resolution. + +7. **Onboarding Workflow**: Multi-step staff onboarding process for vendors. + +8. **Compliance Management**: Dedicated compliance dashboard and document vault. + +9. **Conflict Detection**: Automated scheduling conflict detection with severity levels. + +10. **Communication Hub**: Integrated message center for cross-platform communication. + +11. **Teams Management**: Team creation and assignment workflows. + +12. **Advanced Analytics**: Multiple report types, custom report builder, scheduled reports, and AI-generated insights. + +13. **Dashboard Customization**: Drag-and-drop widget management with layout persistence. + +14. **Schedule & Availability**: Calendar-based staff availability management with visual schedule view. + +15. **Client & Rate Management**: Vendor-side client relationship and service rate management. + +This document now accurately reflects the robust feature set implemented in the krow_web_application. diff --git a/docs/ARCHITECTURE/web.md b/docs/ARCHITECTURE/web.md new file mode 100644 index 00000000..7c353ba8 --- /dev/null +++ b/docs/ARCHITECTURE/web.md @@ -0,0 +1,91 @@ +# KROW Web Application Architecture + +## 1. Overview +The KROW Web Application serves as the "Command Center" for the platform, catering to administrators, HR, finance, and client executives. It is a high-performance, scalable dashboard designed for complex data management and analytics. + +## 2. Tech Stack +- **Framework**: [React 19](https://react.dev/) +- **Build Tool**: [Vite](https://vitejs.dev/) +- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) +- **State Management**: [Redux Toolkit](https://redux-toolkit.js.org/) +- **Data Fetching**: [TanStack Query (React Query)](https://tanstack.com/query/latest) +- **Backend Integration**: Firebase Data Connect + PostgreSQL +- **Language**: TypeScript + +## 3. Monorepo & Project Structure + +### Recommendation: Skip Nx +After evaluating `nx` for the KROW project, the recommendation is to **skip its adoption** at this stage. + +**Reasoning:** +- **Existing Orchestration**: The root `Makefile` and `Melos` (for mobile) already provide a robust orchestration layer. Adding `nx` would introduce redundant complexity. +- **Heterogeneous Stack**: `nx` excels in JS/TS-heavy monorepos. Our project is a mix of Flutter (Dart) and React (TS), which reduces the native benefits of `nx`. +- **Maintainability**: The overhead of learning and maintaining `nx` configurations outweighs the current benefits for a project of this scale. + +### Future Alternative: Turborepo +If caching and task orchestration become a bottleneck for the web/JS side, **Turborepo** is recommended as a lighter alternative that integrates seamlessly with our current `pnpm` setup. + +### Final Project Structure (Unified) +``` +/apps + /web # React Web Dashboard + /mobile # Flutter Mobile Apps (Melos monorepo) +/packages + /design-tokens # Shared Design System (TS/JSON) +/backend + /dataconnect # Shared GraphQL Schemas +/docs + /ARCHITECTURE # Architecture Documentation +/Makefile # Unified Command Orchestrator +``` + +## 4. Shared Design System + +### Package: `@krow/design-tokens` +A dedicated package at `/packages/design-tokens` serves as the single source of truth for design constants across all platforms. + +**Folder Structure:** +``` +/packages/design-tokens + /src + /colors.ts # Color palette definitions + /typography.ts # Typography scale and font families + /index.ts # Main export entry + /package.json + /tsconfig.json +``` + +### Color Palette (Aligned with Mobile) +Based on `UiColors` from the mobile app: +- **Primary**: `#0A39DF` (Brand Blue) +- **Accent**: `#F9E547` (Accent Yellow) +- **Background**: `#FAFBFC` +- **Foreground**: `#121826` +- **Secondary**: `#F1F3F5` +- **Muted**: `#F1F3F5` +- **Destructive**: `#F04444` (Error Red) +- **Success**: `#10B981` (Success Green) +- **Border**: `#D1D5DB` + +### Typography Scale (Aligned with Mobile) +- **Primary Font**: Instrument Sans +- **Secondary Font**: Space Grotesk +- **Scales**: + - **Display L**: 36px, Height 1.1 + - **Display M**: 32px, Height 1.1 + - **Title 1**: 18px, Height 1.5 + - **Body 1**: 16px, Height 1.5 + - **Body 2**: 14px, Height 1.5 + +### Implementation Strategy +1. **Definition**: Define tokens in TypeScript (or JSON) within `/packages/design-tokens`. +2. **Web Consumption**: Export tokens for use in `tailwind.config.ts` or as CSS variables. +3. **Mobile Consumption**: Use a script to generate `ui_colors.dart` and `ui_typography.dart` from the shared tokens to ensure perfect alignment. + +## 5. Web Application Organization +The web application follows a **feature-based** modular architecture: +- `src/features/`: Contains feature-specific logic, components, and hooks (e.g., `billing`, `scheduling`, `admin`). +- `src/components/shared/`: Reusable UI components built with Tailwind. +- `src/hooks/`: Shared React hooks. +- `src/store/`: Redux slices for global state. +- `src/dataconnect-generated/`: SDK generated by Firebase Data Connect. diff --git a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md new file mode 100644 index 00000000..b3f860ab --- /dev/null +++ b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md @@ -0,0 +1,365 @@ +# KROW Workforce API Contracts + +This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**. + +--- + +## Staff Application + +### Authentication / Onboarding Pages +*(Pages: get_started_page.dart, intro_page.dart, phone_verification_page.dart, profile_setup_page.dart)* + +#### Setup / User Validation API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if the user is STAFF). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, role }` | +| **Notes** | Required after OTP verification to route users appropriately. | + +#### Create Default User API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `POST /users` | +| **Data Connect OP** | `createUser` | +| **Purpose** | Inserts a base user record into the system during initial signup. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `role: UserBaseRole` | +| **Outputs** | `id` of newly created User | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't physically exist in the database. | + +#### Get Staff Profile API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/user/{userId}` | +| **Data Connect OP** | `getStaffByUserId` | +| **Purpose** | Finds the specific Staff record associated with the base user ID. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | +| **Notes** | Needed to verify if a complete staff profile exists before allowing navigation to the main app dashboard. | + +#### Update Staff Profile API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `PUT /staff/{id}` | +| **Data Connect OP** | `updateStaff` | +| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `address`, etc. | +| **Outputs** | `id` | +| **Notes** | Called incrementally during the profile setup wizard as the user fills out step-by-step information. | + +### Home Page & Benefits Overview +*(Pages: worker_home_page.dart, benefits_overview_page.dart)* + +#### Load Today/Tomorrow Shifts +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/{staffId}/applications` | +| **Data Connect OP** | `getApplicationsByStaffId` | +| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | +| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to successfully display "Today's" and "Tomorrow's" shifts. | + +#### List Recommended Shifts +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /shifts/recommended` | +| **Data Connect OP** | `listShifts` | +| **Purpose** | Fetches open shifts that are available for the staff to apply to. | +| **Operation** | Query | +| **Inputs** | None directly mapped on load, but fetches available items logically. | +| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on an active backend `$status: OPEN` parameter. | + +#### Benefits Summary API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/{staffId}/benefits` | +| **Data Connect OP** | `listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display gracefully on the home screen. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | +| **Notes** | Used by `benefits_overview_page.dart`. Derives available metrics via `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages +*(Pages: shifts_page.dart, shift_details_page.dart)* + +#### List Available Shifts Filtered +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /shifts` | +| **Data Connect OP** | `filterShifts` | +| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | +| **Operation** | Query | +| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | +| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | +| **Notes** | Main driver for discovering available work. | + +#### Get Shift Details +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /shifts/{id}` | +| **Data Connect OP** | `getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform requirements and managers. | +| **Operation** | Query | +| **Inputs** | `id: UUID!` | +| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | +| **Notes** | Invoked when users click into a full `shift_details_page.dart`. | + +#### Apply To Shift +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `POST /applications` | +| **Data Connect OP** | `createApplication` | +| **Purpose** | Worker submits an intent to take an open shift (creates an application record). | +| **Operation** | Mutation | +| **Inputs** | `shiftId: UUID!`, `staffId: UUID!`, `roleId: UUID!`, `status: ApplicationStatus!` (e.g. `PENDING` or `CONFIRMED`), `origin: ApplicationOrigin!` (e.g. `STAFF`); optional: `checkInTime`, `checkOutTime` | +| **Outputs** | `application_insert.id` (Application ID) | +| **Notes** | The app uses `status: CONFIRMED` and `origin: STAFF` when claiming; backend also supports `PENDING` for admin review flows. After creation, shift-role assigned count and shift filled count are updated. | + +### Availability Page +*(Pages: availability_page.dart)* + +#### Get Default Availability +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/{staffId}/availabilities` | +| **Data Connect OP** | `listStaffAvailabilitiesByStaffId` | +| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | +| **Notes** | Bound to Monday through Sunday configuration. | + +#### Update Availability +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `PUT /staff/availabilities/{id}` | +| **Data Connect OP** | `updateStaffAvailability` (or `createStaffAvailability` for new entries) | +| **Purpose** | Upserts availability preferences. | +| **Operation** | Mutation | +| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | +| **Outputs** | `id` | +| **Notes** | Called individually per day edited. | + +### Payments Page +*(Pages: payments_page.dart, early_pay_page.dart)* + +#### Get Recent Payments +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/{staffId}/payments` | +| **Data Connect OP** | `listRecentPaymentsByStaffId` | +| **Purpose** | Loads the history of earnings and timesheets completed by the staff. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `Payments { amount, processDate, shiftId, status }` | +| **Notes** | Displays historical metrics under the comprehensive Earnings tab. | + +### Compliance / Profiles +*(Pages: certificates_page.dart, documents_page.dart, tax_forms_page.dart, form_i9_page.dart, form_w4_page.dart)* + +#### Get Tax Forms +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /staff/{staffId}/tax-forms` | +| **Data Connect OP** | `getTaxFormsByStaffId` | +| **Purpose** | Check the filing status and detailed inputs of I9 and W4 forms. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | +| **Notes** | Crucial requirement for staff to be eligible to apply for highly regulated shifts. | + +#### Update Tax Forms +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `PUT /tax-forms/{id}` | +| **Data Connect OP** | `updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type (W4/I9). | +| **Operation** | Mutation | +| **Inputs** | `id`, `dataPoints...` | +| **Outputs** | `id` | +| **Notes** | Modifies the core compliance state variables directly. | + +--- + +## Client Application + +### Authentication / Intro +*(Pages: client_sign_in_page.dart, client_get_started_page.dart)* + +#### Client User Validation API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (ensuring user is BUSINESS). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, userRole }` | +| **Notes** | Validates against conditional statements checking `userRole == BUSINESS` or `BOTH`. | + +#### Get Businesses By User API +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/user/{userId}` | +| **Data Connect OP** | `getBusinessesByUserId` | +| **Purpose** | Maps the authenticated user to their client business context. | +| **Operation** | Query | +| **Inputs** | `userId: String!` | +| **Outputs** | `Businesses { id, businessName, email, contactName }` | +| **Notes** | Dictates the working scopes (Business ID) across the entire application lifecycle and binds the user. | + +### Hubs Page +*(Pages: client_hubs_page.dart, edit_hub_page.dart, hub_details_page.dart)* + +#### List Hubs by Team +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /teams/{teamId}/hubs` | +| **Data Connect OP** | `getTeamHubsByTeamId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client context by using Team mapping. | +| **Operation** | Query | +| **Inputs** | `teamId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, managerName, isActive }` | +| **Notes** | `teamId` is derived first from `getTeamsByOwnerId(ownerId: businessId)`. | + +#### Create / Update / Delete Hub +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `POST /team-hubs` / `PUT /team-hubs/{id}` / `DELETE /team-hubs/{id}` | +| **Data Connect OP** | `createTeamHub` / `updateTeamHub` / `deleteTeamHub` | +| **Purpose** | Provisions, Edits details directly, or Removes a Team Hub location. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, optionally `hubName`, `address`, etc. | +| **Outputs** | `id` | +| **Notes** | Fired from `edit_hub_page.dart` mutations. | + +### Orders Page +*(Pages: create_order_page.dart, view_orders_page.dart, recurring_order_page.dart)* + +#### Create Order +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `POST /orders` | +| **Data Connect OP** | `createOrder` | +| **Purpose** | Submits a new request for temporary staff requirements. | +| **Operation** | Mutation | +| **Inputs** | `businessId`, `eventName`, `orderType`, `status` | +| **Outputs** | `id` (Order ID) | +| **Notes** | This explicitly invokes an order pipeline, meaning Shift instances are subsequently created through secondary mutations triggered after order instantiation. | + +#### List Orders +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/orders` | +| **Data Connect OP** | `listOrdersByBusinessId` | +| **Purpose** | Retrieves all ongoing and past staff requests from the client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Orders { id, eventName }` | +| **Notes** | Populates the `view_orders_page.dart`. | + +### Billing Pages +*(Pages: billing_page.dart, pending_invoices_page.dart, completion_review_page.dart)* + +#### List Invoices +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/invoices` | +| **Data Connect OP** | `listInvoicesByBusinessId` | +| **Purpose** | Fetches all invoices bound directly to the active business context (mapped directly in Firebase Schema). | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Invoices { id, amount, issueDate, status }` | +| **Notes** | Used massively across all Billing view tabs. | + +#### Mark / Dispute Invoice +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `PUT /invoices/{id}` | +| **Data Connect OP** | `updateInvoice` | +| **Purpose** | Actively marks an invoice as disputed or pays it directly (altering status). | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `status: InvoiceStatus` | +| **Outputs** | `id` | +| **Notes** | Disputing usually involves setting a `disputeReason` flag state dynamically via builder pattern. | + +### Reports Page +*(Pages: reports_page.dart, coverage_report_page.dart, performance_report_page.dart)* + +#### Get Coverage Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/coverage` | +| **Data Connect OP** | `listShiftsForCoverage` | +| **Purpose** | Provides data on Shifts grouped by Date for fulfillment calculations. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, filled, status }` | +| **Notes** | The frontend aggregates the raw backend rows to compose Coverage percentage natively. | + +#### Get Daily Ops Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/dailyops` | +| **Data Connect OP** | `listShiftsForDailyOpsByBusiness` | +| **Purpose** | Supplies current day operations and shift tracking progress. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `date: Timestamp!` | +| **Outputs** | `Shifts { id, title, location, workersNeeded, filled }` | +| **Notes** | - | + +#### Get Forecast Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/forecast` | +| **Data Connect OP** | `listShiftsForForecastByBusiness` | +| **Purpose** | Retrieves scheduled future shifts to calculate financial run-rates. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, hours, cost }` | +| **Notes** | The App maps hours `x` cost to deliver Financial Dashboards. | + +#### Get Performance KPIs +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/performance` | +| **Data Connect OP** | `listShiftsForPerformanceByBusiness` | +| **Purpose** | Fetches historical data allowing time-to-fill and completion-rate calculations. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, workersNeeded, filled, createdAt, filledAt }` | +| **Notes** | Data Connect exposes timestamps so the App calculates `avgFillTimeHours`. | + +#### Get No-Show Metrics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/noshows` | +| **Data Connect OP** | `listShiftsForNoShowRangeByBusiness` | +| **Purpose** | Retrieves shifts where workers historically ghosted the platform. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date }` | +| **Notes** | Accompanies `listApplicationsForNoShowRange` cascading querying to generate full report. | + +#### Get Spend Analytics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/spend` | +| **Data Connect OP** | `listInvoicesForSpendByBusiness` | +| **Purpose** | Detailed invoice aggregates for Spend metrics filtering. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Invoices { id, issueDate, dueDate, amount, status }` | +| **Notes** | Used explicitly under the "Spend Report" graphings. | + +--- diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd diff --git a/docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md b/docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md similarity index 100% rename from docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md rename to docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md diff --git a/docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md b/docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md similarity index 100% rename from docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md rename to docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md diff --git a/docs/BACKEND/DATACONNECT_GUIDES/backend_cloud_run_functions.md b/docs/BACKEND/DATACONNECT_GUIDES/backend_cloud_run_functions.md new file mode 100644 index 00000000..a8214808 --- /dev/null +++ b/docs/BACKEND/DATACONNECT_GUIDES/backend_cloud_run_functions.md @@ -0,0 +1,183 @@ +# Backend Cloud Run / Functions Guide + +## 1) Validate Shift Acceptance (Worker) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Shift acceptance must be enforced serverโ€‘side to prevent bypassing the client. +- It must be raceโ€‘condition safe (two accepts at the same time). +- It needs to be extensible for future eligibility rules. + +**Proposed backend solution** +Add a single command endpoint: +- `POST /shifts/:shiftId/accept` + +**Backend flow** +- Verify Firebase Auth token + permissions (worker identity). +- Run an extensible validation pipeline (pluggable rules): + - `NoOverlapRule` (M4) + - Future rules can be added without changing core logic. +- Apply acceptance in a DB transaction (atomic). +- Return a clear error payload on rejection: + - `409 CONFLICT` (overlap) with `{ code, message, conflictingShiftIds }` + +--- + +## 2) Validate Shift Creation by a Client (Minimum Hours โ€” soft check) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Creation rules must be enforced serverโ€‘side so clients canโ€™t bypass validations by skipping the UI or calling APIs directly. +- We want a scalable rule system so new creation checks can be added without rewriting core logic. + +**Proposed backend solution** +Add/route creation through a backend validation layer (Cloud Run endpoint or a dedicated โ€œcreate orderโ€ command). + +**On create** +- Compute shift duration and compare against vendor minimum (current: **5 hours**). +- Return a consistent validation response when below minimum, e.g.: + - `200 OK` with `{ valid: false, severity: "SOFT", code: "MIN_HOURS", message, minHours: 5 }` + - (or `400` only if we decide it should block creation; for now itโ€™s a soft check) + +**FE note** +- Show the same message before submission (UX feedback), but backend remains the source of truth. + +--- + +## 3) Enforce Cancellation Policy (no cancellations within 24 hours) +**Best fit:** Cloud Run + +**Why backend logic is required** +- Cancellation restrictions must be enforced serverโ€‘side to prevent policy bypass. +- Ensures consistent behavior across web/mobile and future clients. + +**Proposed backend solution** +Add a backend command endpoint for cancel: +- `POST /shifts/:shiftId/cancel` (or `/orders/:id/cancel` depending on ownership model) + +**Backend checks** +- If `now >= shiftStart - 24h`, reject cancellation. + +**Error response** +- `403 FORBIDDEN` (or `409 CONFLICT`) with `{ code: "CANCEL_WINDOW_LOCKED", message, windowHours: 24, penalty: }` +- Once penalty is finalized, include it in the response and logs/audit trail. + +--- + +## 4) Implement Worker Documentation Upload Process +**Best fit:** Cloud Functions v2 + Cloud Storage + +**Why backend logic is required** +- Uploads must be stored securely and reliably linked to the correct worker profile. +- Requires serverโ€‘side auth and auditing. + +**Proposed backend solution** +- HTTP/Callable Function: `uploadInit(workerId, docType)` โ†’ returns signed upload URL + `documentId`. +- Client uploads directly to Cloud Storage. +- Storage trigger (`onFinalize`) or `uploadComplete(documentId)`: + - Validate uploader identity/ownership. + - Store metadata in DB (type, path, status, timestamps). + - Link document to worker profile. +- Enforce access control (worker/self, admins, authorized client reviewers). + +--- + +## 5) Parse Uploaded Documentation for Verification +**Best fit:** Cloud Functions (eventโ€‘driven) or Cloud Run worker (async) + +**Why backend logic is required** +- Parsing should run asynchronously. +- Store structured results for review while keeping manual verification as the final authority. + +**Proposed backend solution** +- Trigger on Storage upload finalize: + - OCR/AI extract key fields โ†’ store structured output (`parsedFields`, `confidence`, `aiStatus`). +- Keep manual review: + - Client can approve/override AI results. + - Persist reviewer decision + audit trail. + +--- + +## 6) Support Attire Upload for Workers +**Best fit:** Cloud Functions v2 + Cloud Storage + +**Why backend logic is required** +- Attire images must be securely stored and linked to the correct worker profile. +- Requires serverโ€‘side authorization. + +**Proposed backend solution** +- HTTP/Callable Function: `attireUploadInit(workerId)` โ†’ signed upload URL + `attireAssetId`. +- Client uploads to Cloud Storage. +- Storage trigger (`onFinalize`) or `attireUploadComplete(attireAssetId)`: + - Validate identity/ownership. + - Store metadata and link to worker profile. + +--- + +## 7) Verify Attire Images Against Shift Dress Code +**Best fit:** Cloud Functions (trigger) or Cloud Run worker (async) + +**Why backend logic is required** +- Verification must be enforced serverโ€‘side. +- Must remain reviewable/overrideable by the client even if AI passes. + +**Proposed backend solution** +- Async verification triggered after upload or when tied to a shift: + - Evaluate dress code rules (and optional AI). + - Store results `{ status, reasons, evidence, confidence }`. +- Client can manually approve/override; audit every decision. + +--- + +## 8) Support Shifts Requiring โ€œAwaiting Confirmationโ€ Status +**Best fit:** Cloud Run (domain state transitions) + +**Why backend logic is required** +- State transitions must be enforced serverโ€‘side. +- Prevent invalid bookings and support future workflow rules. + +**Proposed backend solution** +- Add status flow: `AWAITING_CONFIRMATION โ†’ BOOKED/ACTIVE` (per lifecycle). +- Command endpoint: `POST /shifts/:id/confirm`. + +**Backend validates** +- Caller is the assigned worker. +- Shift is still eligible (not started/canceled/overlapped, etc.). +- Persist transition + audit event. + +--- + +## 9) Enable NFCโ€‘Based Clockโ€‘In and Clockโ€‘Out +**Best fit:** Cloud Run (secure API) + optional Cloud Functions for downstream events + +**Why backend logic is required** +- Clockโ€‘in/out is securityโ€‘sensitive and must be validated serverโ€‘side. +- Requires strong auditing and antiโ€‘fraud checks. + +**Proposed backend solution** +API endpoints: +- `POST /attendance/clock-in` +- `POST /attendance/clock-out` + +**Validate** +- Firebase identity. +- NFC tag legitimacy (mapped to hub/location). +- Time window rules + prevent duplicates/inconsistent sequences. + +**Persist** +- Store immutable events + derived attendance record. +- Emit audit logs/alerts if needed. + +--- + +## 10) Update Recurring & Permanent Orders (Backend) +**Best fit:** Cloud Run + +**Why backend logic is required** +Updating a recurring or permanent order is not a single update. It may affect **N shifts** and **M shift roles**, and requires extra validations, such as: +- Prevent editing shifts that already started. +- Prevent removing or reducing roles with assigned staff. +- Control whether changes apply to future only, from a given date, or all. +- Ensure data consistency (allโ€‘orโ€‘nothing updates). + +These operations can take time and must be enforced serverโ€‘side, even if the client is bypassed. diff --git a/docs/MOBILE/00-agent-development-rules.md b/docs/MOBILE/00-agent-development-rules.md new file mode 100644 index 00000000..5ef0a8b7 --- /dev/null +++ b/docs/MOBILE/00-agent-development-rules.md @@ -0,0 +1,135 @@ +# Agent Development Rules + +These rules are **NON-NEGOTIABLE**. They are designed to prevent architectural degradation by automated agents. + +## 1. File Creation & Structure + +1. **Feature-First Packaging**: + * **DO**: Create new features as independent packages in `apps/mobile/packages/features//`. + * **DO NOT**: Add features to `apps/mobile/packages/core` or existing apps directly. + * **DO NOT**: Create cross-feature or cross-app dependencies. +2. **Path Conventions**: + * Entities: `apps/mobile/packages/domain/lib/src/entities/.dart` + * Repositories (Interface): `apps/mobile/packages/features///lib/src/domain/repositories/_repository_interface.dart` + * Repositories (Impl): `apps/mobile/packages/features///lib/src/data/repositories_impl/_repository_impl.dart` + * Use Cases: `apps/mobile/packages/features///lib/src/application/_usecase.dart` + * BLoCs: `apps/mobile/packages/features///lib/src/presentation/blocs/_bloc.dart` + * Pages: `apps/mobile/packages/features///lib/src/presentation/pages/_page.dart` + * Widgets: `apps/mobile/packages/features///lib/src/presentation/widgets/_widget.dart` +3. **Barrel Files**: + * **DO**: Use `export` in `lib/.dart` only for public APIs. + * **DO NOT**: Export internal implementation details in the main package file. + +## 2. Naming Conventions + +Follow Dart standards strictly. + +| Type | Convention | Example | +| :--- | :--- | :--- | +| **Files** | `snake_case` | `user_profile_page.dart` | +| **Classes** | `PascalCase` | `UserProfilePage` | +| **Variables** | `camelCase` | `userProfile` | +| **Interfaces** | terminate with `Interface` | `AuthRepositoryInterface` | +| **Implementations** | terminate with `Impl` | `AuthRepositoryImpl` | + +## 3. Logic Placement (Strict Boundaries) + +* **Business Rules**: MUST reside in **Use Cases** (Domain/Feature Application layer). + * *Forbidden*: Placing business rules in BLoCs or Widgets. +* **State Logic**: MUST reside in **BLoCs** or **StatefulWidgets** (only for ephemeral UI state). + * *Forbidden*: `setState` in Pages for complex state management. + * **Recommendation**: Pages should be `StatelessWidget` with state delegated to BLoCs. +* **Data Transformation**: MUST reside in **Repositories** (Data Connect layer). + * *Forbidden*: Parsing JSON in the UI or Domain. + * **Pattern**: Repositories map Data Connect models to Domain entities. +* **Navigation Logic**: MUST reside in **Flutter Modular Routes**. + * *Forbidden*: `Navigator.push` with hardcoded widgets. + * **Pattern**: Use named routes via `Modular.to.navigate()`. +* **Session Management**: MUST reside in **DataConnectService** via **SessionHandlerMixin**. + * **Pattern**: Automatic token refresh, auth state listening, and role-based validation. + * **UI Reaction**: **SessionListener** widget wraps the entire app and responds to session state changes. + +## 4. Localization (core_localization) Integration + +All user-facing text MUST be localized using the centralized `core_localization` package: + +1. **String Management**: + * Define all user-facing strings in `apps/mobile/packages/core_localization/lib/src/l10n/` + * Use `slang` or similar i18n tooling for multi-language support + * Access strings in presentation layer via `context.strings.` +2. **BLoC Integration**: + * `LocaleBloc` manages the current locale state + * Apps import `core_localization.LocalizationModule()` in their module imports + * Wrap app with `BlocProvider()` to expose locale state globally +3. **Feature Usage**: + * Pages and widgets access localized strings: `Text(context.strings.buttonLabel)` + * Build methods receive `BuildContext` with access to current locale + * No hardcoded English strings in feature packages +4. **Error Messages**: + * Use `ErrorTranslator` from `core_localization` to map domain failures to user-friendly messages + * **Pattern**: Failures emitted from BLoCs are translated to localized strings in the UI + +## 5. Data Connect Integration Strategy + +All backend access is centralized through `DataConnectService` in `apps/mobile/packages/data_connect`: + +1. **Repository Interface First**: Define `abstract interface class RepositoryInterface` in the feature's `domain/repositories/` folder. +2. **Repository Implementation**: Implement the interface in `data/repositories_impl/` using `_service.run()` wrapper. + * **Pattern**: `await _service.run(() => connector.().execute())` + * **Benefit**: Automatic auth validation, token refresh, and error handling. +3. **Session Handling**: Use `DataConnectService.instance.initializeAuthListener(allowedRoles: [...])` in app main.dart. + * **Automatic**: Token refresh with 5-minute expiry threshold. + * **Retry Logic**: 3 attempts with exponential backoff (1s โ†’ 2s โ†’ 4s) before emitting error. + * **Role Validation**: Configurable per app (e.g., Staff: `['STAFF', 'BOTH']`, Client: `['CLIENT', 'BUSINESS', 'BOTH']`). +4. **Session State Management**: Wrap app with `SessionListener` widget to react to session changes. + * **Dialogs**: Shows session expired or error dialogs for user-facing feedback. + * **Navigation**: Routes to login on session loss, to home on authentication. + +## 5. Prototype Migration Rules + +You have access to `prototypes/` folders. When migrating code: + +1. **Extract Assets**: + * You MAY copy icons, images, and colors. But they should be tailored to the current design system. Do not change the colours and typgorahys in the design system. They are final. And you have to use these in the UI. + * When you matching colous and typography, from the POC match it with the design system and use the colors and typography from the design system. As mentioned in the `apps/mobile/docs/03-design-system-usage.md`. +2. **Extract Layouts**: You MAY copy `build` methods for UI structure. +3. **REJECT Architecture**: You MUST **NOT** copy the `GetX`, `Provider`, or `MVC` patterns often found in prototypes. Refactor immediately to **Bloc + Clean Architecture with Flutter Modular and Melos**. + +## 6. Handling Ambiguity + +If a user request is vague: + +1. **STOP**: Do not guess domain fields or workflows. +2. **ANALYZE**: + - For architecture related questions, refer to `apps/mobile/docs/01-architecture-principles.md` or existing code. + - For design system related questions, refer to `apps/mobile/docs/03-design-system-usage.md` or existing code. +3. **DOCUMENT**: If you must make an assumption to proceed, add a comment `// ASSUMPTION: ` and mention it in your final summary. +4. **ASK**: Prefer asking the user for clarification on business rules (e.g., "Should a 'Job' have a 'status'?"). + +## 7. Dependencies + +* **DO NOT** add 3rd party packages without checking `apps/mobile/packages/core` first. +* **DO NOT** add `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only. +* **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations. +* **Dependency Injection**: Use Flutter Modular for BLoC (never use `addSingleton` for Blocs, always use `add` method) and UseCase injection in `Module.routes()`. + +## 8. Error Handling + +* **Domain Failures**: Define custom `Failure` classes in `domain/failures/`. +* **Data Connect Errors**: Map Data Connect exceptions to Domain failures in repositories. +* **User Feedback**: BLoCs emit error states; UI displays snackbars or dialogs. +* **Session Errors**: SessionListener automatically shows dialogs for session expiration/errors. + +## 9. Testing + +* **Unit Tests**: Test use cases and repositories with real implementations. +* **Widget Tests**: Use `WidgetTester` to test UI widgets and BLoCs. +* **Integration Tests**: Test full feature flows end-to-end with Data Connect. +* **Pattern**: Use dependency injection via Modular to swap implementations if needed for testing. + +## 10. Follow Clean Code Principles + +* Add doc comments to all classes and methods you create. +* Keep methods and classes focused and single-responsibility. +* Use meaningful variable names that reflect intent. +* Keep widget build methods concise; extract complex widgets to separate files. diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md new file mode 100644 index 00000000..b37833ca --- /dev/null +++ b/docs/MOBILE/01-architecture-principles.md @@ -0,0 +1,378 @@ +# KROW Architecture Principles + +This document is the **AUTHORITATIVE** source of truth for the KROW engineering architecture. +All agents and engineers must adhere strictly to these principles. Deviations are interpreted as errors. + +## 1. High-Level Architecture + +The KROW platform follows a strict **Clean Architecture** implementation within a **Melos Monorepo**. +Dependencies flow **inwards** towards the Domain. + +```mermaid +graph TD + subgraph "Apps (Entry Points)" + ClientApp["apps/mobile/apps/client"] + StaffApp["apps/mobile/apps/staff"] + end + + subgraph "Features" + ClientFeatures["apps/mobile/packages/features/client/*"] + StaffFeatures["apps/mobile/packages/features/staff/*"] + end + + subgraph "Services" + DataConnect["apps/mobile/packages/data_connect"] + DesignSystem["apps/mobile/packages/design_system"] + CoreLocalization["apps/mobile/packages/core_localization"] + end + + subgraph "Core Domain" + Domain["apps/mobile/packages/domain"] + Core["apps/mobile/packages/core"] + end + + %% Dependency Flow + ClientApp --> ClientFeatures & DataConnect & CoreLocalization + StaffApp --> StaffFeatures & DataConnect & CoreLocalization + + ClientFeatures & StaffFeatures --> Domain + ClientFeatures & StaffFeatures --> DesignSystem + ClientFeatures & StaffFeatures --> CoreLocalization + ClientFeatures & StaffFeatures --> Core + + DataConnect --> Domain + DataConnect --> Core + DesignSystem --> Core + CoreLocalization --> Core + Domain --> Core + + %% Strict Barriers + linkStyle default stroke-width:2px,fill:none,stroke:gray +``` + +## 2. Repository Structure & Package Roles + +### 2.1 Apps (`apps/mobile/apps/`) +- **Role**: Application entry points and Dependency Injection (DI) roots. +- **Responsibilities**: + - Initialize Flutter Modular. + - Assemble features into a navigation tree. + - Inject concrete implementations (from `data_connect`) into Feature packages. + - Configure environment-specific settings. +- **RESTRICTION**: NO business logic. NO UI widgets (except `App` and `Main`). + +### 2.2 Features (`apps/mobile/packages/features//`) +- **Role**: Vertical slices of user-facing functionality. +- **Internal Structure**: + - `domain/`: Feature-specific Use Cases(always extend the apps/mobile/packages/core/lib/src/domain/usecases/usecase.dart abstract clas) and Repository Interfaces. + - `data/`: Repository Implementations. + - `presentation/`: + - Pages, BLoCs, Widgets. + - For performance make the pages as `StatelessWidget` and move the state management to the BLoC (always use a BlocProvider when providing the BLoC to the widget tree) or `StatefulWidget` to an external separate widget file. +- **Responsibilities**: + - **Presentation**: UI Pages, Modular Routes. + - **State Management**: BLoCs / Cubits. + - **Application Logic**: Use Cases. +- **RESTRICTION**: Features MUST NOT import other features. Communication happens via shared domain events. + +### 2.3 Domain (`apps/mobile/packages/domain`) +- **Role**: The stable heart of the system. Pure Dart. +- **Responsibilities**: + - **Entities**: Immutable data models (Data Classes). + - **Failures**: Domain-specific error types. +- **RESTRICTION**: NO Flutter dependencies. NO `json_annotation`. NO package dependencies (except `equatable`). + +### 2.4 Data Connect (`apps/mobile/packages/data_connect`) +- **Role**: Interface Adapter for Backend Access (Datasource Layer). +- **Responsibilities**: + - **Connectors**: Centralized repository implementations for each backend connector (see `03-data-connect-connectors-pattern.md`) + - One connector per backend connector domain (staff, order, user, etc.) + - Repository interfaces and use cases defined at domain level + - Repository implementations query backend and map responses + - Implement Firebase Data Connect connector and service layer + - Map Domain Entities to/from Data Connect generated code + - Handle Firebase exceptions and map to domain failures + - Provide centralized `DataConnectService` with session management +- **RESTRICTION**: + - NO feature-specific logic. Connectors are domain-neutral and reusable. + - All queries must follow Clean Architecture (domain โ†’ data layers) + - See `03-data-connect-connectors-pattern.md` for detailed pattern documentation + +### 2.5 Design System (`apps/mobile/packages/design_system`) +- **Role**: Visual language and component library. +- **Responsibilities**: + - UI components if needed. But mostly try to modify the theme file (apps/mobile/packages/design_system/lib/src/ui_theme.dart) so we can directly use the theme in the app, to use the default material widgets. + - If not possible, and if that specific widget is used in multiple features, then try to create a shared widget in the `apps/mobile/packages/design_system/widgets`. + - Theme definitions (Colors, Typography). + - Assets (Icons, Images). + - More details on how to use this package is available in the `apps/mobile/docs/03-design-system-usage.md`. +- **RESTRICTION**: + - CANNOT change colours or typography. + - Dumb widgets only. NO business logic. NO state management (Bloc). + - More details on how to use this package is available in the `apps/mobile/docs/03-design-system-usage.md`. + +### 2.6 Core Localization (`apps/mobile/packages/core_localization`) +- **Role**: Centralized language and localization management. +- **Responsibilities**: + - Define all user-facing strings in `l10n/` with i18n tooling support + - Provide `LocaleBloc` for reactive locale state management + - Export `TranslationProvider` for BuildContext-based string access + - Map domain failures to user-friendly localized error messages via `ErrorTranslator` +- **Feature Integration**: + - Features access strings via `context.strings.` in presentation layer + - BLoCs don't depend on localization; they emit domain failures + - Error translation happens in UI layer (pages/widgets) +- **App Integration**: + - Apps import `LocalizationModule()` in their module imports + - Apps wrap the material app with `BlocProvider()` and `TranslationProvider` + - Apps initialize `MaterialApp` with locale from `LocaleState` + +### 2.7 Core (`apps/mobile/packages/core`) +- **Role**: Cross-cutting concerns. +- **Responsibilities**: + - Extension methods. + - Logger configuration. + - Base classes for Use Cases or Result types (functional error handling). + +## 3. Dependency Direction & Boundaries + +1. **Domain Independence**: `apps/mobile/packages/domain` knows NOTHING about the outer world. It defines *what* needs to be done, not *how*. +2. **UI Agnosticism**: `apps/mobile/packages/features` depends on `apps/mobile/packages/design_system` for looks and `apps/mobile/packages/domain` for logic. It does NOT know about Firebase. +3. **Data Isolation**: `apps/mobile/packages/data_connect` depends on `apps/mobile/packages/domain` to know what interfaces to implement. It does NOT know about the UI. + +## 4. Data Connect Service & Session Management + +All backend access is unified through `DataConnectService` with integrated session management: + +### 4.1 Session Handler Mixin +- **Location**: `apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart` +- **Responsibilities**: + - Automatic token refresh (triggered when token <5 minutes to expiry) + - Firebase auth state listening + - Role-based access validation + - Session state stream emissions + - 3-attempt retry logic with exponential backoff on token validation failure +- **Key Method**: `initializeAuthListener(allowedRoles: [...])` - call once on app startup + +### 4.2 Session Listener Widget +- **Location**: `apps/mobile/apps//lib/src/widgets/session_listener.dart` +- **Responsibilities**: + - Wraps entire app to listen to session state changes + - Shows user-friendly dialogs for session expiration/errors + - Handles navigation on auth state changes +- **Pattern**: `SessionListener(child: AppWidget())` + +### 4.3 Repository Pattern with Data Connect +1. **Interface First**: Define `abstract interface class RepositoryInterface` in feature domain layer. +2. **Implementation**: Use `_service.run()` wrapper that automatically: + - Validates user is authenticated (if required) + - Ensures token is valid and refreshes if needed + - Executes the Data Connect query + - Handles exceptions and maps to domain failures +3. **Session Store Population**: On successful auth, session stores are populated: + - Staff: `StaffSessionStore.instance.setSession(StaffSession(...))` + - Client: `ClientSessionStore.instance.setSession(ClientSession(...))` +4. **Lazy Loading**: If session is null, fetch data via `getStaffById()` or `getBusinessById()` and update store. + +## 5. Feature Isolation & Cross-Feature Communication + +- **Zero Direct Imports**: `import 'package:feature_a/...'` is FORBIDDEN inside `package:feature_b`. + - Exception: Shared packages like `domain`, `core`, and `design_system` are always accessible. +- **Navigation**: Use named routes via Flutter Modular: + - **Pattern**: `Modular.to.navigate('route_name')` + - **Configuration**: Routes defined in `module.dart` files; constants in `paths.dart` +- **Data Sharing**: Features do not share state directly. Shared data accessed through: + - **Domain Repositories**: Centralized data sources (e.g., `AuthRepository`) + - **Session Stores**: `StaffSessionStore` and `ClientSessionStore` for app-wide user context + - **Event Streams**: If needed, via `DataConnectService` streams for reactive updates + +## 6. App-Specific Session Management + +Each app (`staff` and `client`) has different role requirements and session patterns: + +### 6.1 Staff App Session +- **Location**: `apps/mobile/apps/staff/lib/main.dart` +- **Initialization**: `DataConnectService.instance.initializeAuthListener(allowedRoles: ['STAFF', 'BOTH'])` +- **Session Store**: `StaffSessionStore` with `StaffSession(user: User, staff: Staff?, ownerId: String?)` +- **Lazy Loading**: `getStaffName()` fetches via `getStaffById()` if session null +- **Navigation**: On auth โ†’ `Modular.to.toStaffHome()`, on unauth โ†’ `Modular.to.toInitialPage()` + +### 6.2 Client App Session +- **Location**: `apps/mobile/apps/client/lib/main.dart` +- **Initialization**: `DataConnectService.instance.initializeAuthListener(allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'])` +- **Session Store**: `ClientSessionStore` with `ClientSession(user: User, business: ClientBusinessSession?)` +- **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null +- **Navigation**: On auth โ†’ `Modular.to.toClientHome()`, on unauth โ†’ `Modular.to.toInitialPage()` + +## 7. Data Connect Connectors Pattern + +See **`03-data-connect-connectors-pattern.md`** for comprehensive documentation on: +- How connector repositories work +- How to add queries to existing connectors +- How to create new connectors +- Integration patterns with features +- Benefits and anti-patterns + +**Quick Reference**: +- All backend queries centralized in `apps/mobile/packages/data_connect/lib/src/connectors/` +- One connector per backend connector domain (staff, order, user, etc.) +- Each connector follows Clean Architecture (domain interfaces + data implementations) +- Features use connector repositories through dependency injection +- Results in zero query duplication and single source of truth + +## 8. Prop Drilling Prevention & Direct BLoC Access + +### 8.1 The Problem: Prop Drilling + +Passing data through intermediate widgets creates maintenance headaches: +- Every intermediate widget must accept and forward props +- Changes to data structure ripple through multiple widget constructors +- Reduces code clarity and increases cognitive load + +**Anti-Pattern Example**: +```dart +// โŒ BAD: Drilling status through 3 levels +ProfilePage(status: status) + โ†’ ProfileHeader(status: status) + โ†’ ProfileLevelBadge(status: status) // Only widget that needs it! +``` + +### 8.2 The Solution: Direct BLoC Access with BlocBuilder + +Use `BlocBuilder` to access BLoC state directly in leaf widgets: + +**Correct Pattern**: +```dart +// โœ… GOOD: ProfileLevelBadge accesses ProfileCubit directly +class ProfileLevelBadge extends StatelessWidget { + const ProfileLevelBadge({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final Staff? profile = state.profile; + if (profile == null) return const SizedBox.shrink(); + + final level = _mapStatusToLevel(profile.status); + return LevelBadgeUI(level: level); + }, + ); + } +} +``` + +### 8.3 Guidelines for Avoiding Prop Drilling + +1. **Leaf Widgets Get Data from BLoC**: Widgets that need specific data should access it directly via BlocBuilder +2. **Container Widgets Stay Simple**: Parent widgets like `ProfileHeader` only manage layout and positioning +3. **No Unnecessary Props**: Don't pass data to intermediate widgets unless they need it for UI construction +4. **Single Responsibility**: Each widget should have one reason to exist + +**Decision Tree**: +``` +Does this widget need data? +โ”œโ”€ YES, and it's a leaf widget โ†’ Use BlocBuilder +โ”œโ”€ YES, and it's a container โ†’ Use BlocBuilder in child, not parent +โ””โ”€ NO โ†’ Don't add prop to constructor +``` + +## 9. BLoC Lifecycle & State Emission Safety + +### 9.1 The Problem: StateError After Dispose + +When async operations complete after a BLoC is closed, attempting to emit state causes: +``` +StateError: Cannot emit new states after calling close +``` + +**Root Causes**: +1. **Transient BLoCs**: `BlocProvider(create:)` creates new instance on every rebuild โ†’ disposed prematurely +2. **Singleton Disposal**: Multiple BlocProviders disposing same singleton instance +3. **Navigation During Async**: User navigates away while `loadProfile()` is still running + +### 9.2 The Solution: Singleton BLoCs + Error Handler Defensive Wrapping + +#### Step 1: Register as Singleton + +```dart +// โœ… GOOD: ProfileCubit as singleton +i.addSingleton( + () => ProfileCubit(useCase1, useCase2), +); + +// โŒ BAD: Creates new instance each time +i.add(ProfileCubit.new); +``` + +#### Step 2: Use BlocProvider.value() for Singletons + +```dart +// โœ… GOOD: Use singleton instance +ProfileCubit cubit = Modular.get(); +BlocProvider.value( + value: cubit, // Reuse same instance + child: MyWidget(), +) + +// โŒ BAD: Creates duplicate instance +BlocProvider( + create: (_) => Modular.get(), // Wrong! + child: MyWidget(), +) +``` + +#### Step 3: Defensive Error Handling in BlocErrorHandler Mixin + +The `BlocErrorHandler` mixin provides `_safeEmit()` wrapper: + +**Location**: `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +```dart +void _safeEmit(void Function(S) emit, S state) { + try { + emit(state); + } on StateError catch (e) { + // Bloc was closed before emit - log and continue gracefully + developer.log( + 'Could not emit state: ${e.message}. Bloc may have been disposed.', + name: runtimeType.toString(), + ); + } +} +``` + +**Usage in Cubits/Blocs**: +```dart +Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await getProfile(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + // โœ… If BLoC disposed before emit, _safeEmit catches StateError gracefully + }, + onError: (errorKey) { + return state.copyWith(status: ProfileStatus.error); + }, + ); +} +``` + +### 9.3 Pattern Summary + +| Pattern | When to Use | Risk | +|---------|------------|------| +| Singleton + BlocProvider.value() | Long-lived features (Profile, Shifts, etc.) | Low - instance persists | +| Transient + BlocProvider(create:) | Temporary widgets (Dialogs, Overlays) | Medium - requires careful disposal | +| Direct BlocBuilder | Leaf widgets needing data | Low - no registration needed | + +**Remember**: +- Use **singletons** for feature-level cubits accessed from multiple pages +- Use **transient** only for temporary UI states +- Always wrap emit() in `_safeEmit()` via `BlocErrorHandler` mixin +- Test navigation away during async operations to verify graceful handling + +``` diff --git a/docs/MOBILE/02-design-system-usage.md b/docs/MOBILE/02-design-system-usage.md new file mode 100644 index 00000000..eeab7c90 --- /dev/null +++ b/docs/MOBILE/02-design-system-usage.md @@ -0,0 +1,155 @@ +# 03 - Design System Usage Guide + +This document defines the mandatory standards for designing and implementing user interfaces across all applications and feature packages using the shared `apps/mobile/packages/design_system`. + +## 1. Introduction & Purpose + +The Design System is the single source of truth for the visual identity of the project. Its purpose is to ensure UI consistency, reduce development velocity by providing reusable primitives, and eliminate "design drift" across multiple feature teams and applications. + +**All UI implementation MUST consume values ONLY from the `design_system` package.** + +### Core Principle +Design tokens (colors, typography, spacing, etc.) are immutable and defined centrally. Features consume these tokens but NEVER modify them. The design system maintains visual coherence across staff and client apps. + +## 2. Design System Ownership & Responsibility + +- **Centralized Authority**: The `apps/mobile/packages/design_system` is the owner of all brand assets, colors, typography, and core components. +- **No Local Overrides**: Feature packages (e.g., `staff_authentication`) are consumers. They are prohibited from defining their own global styles or overriding theme values locally. +- **Extension Policy**: If a required style (color, font, or icon) is missing, the developer must first add it to the `design_system` package following existing patterns before using it in a feature. + +## 3. Package Structure Overview (`apps/mobile/packages/design_system`) + +The package is organized to separate tokens from implementation: +- `lib/src/ui_colors.dart`: Color tokens and semantic mappings. +- `lib/src/ui_typography.dart`: Text styles and font configurations. +- `lib/src/ui_icons.dart`: Exported icon sets. +- `lib/src/ui_constants.dart`: Spacing, radius, and elevation tokens. +- `lib/src/ui_theme.dart`: Centralized `ThemeData` factory. +- `lib/src/widgets/`: Common "Smart Widgets" and reusable UI building blocks. + +## 4. Colors Usage Rules + +Feature packages **MUST NOT** define custom hex codes or `Color` constants. + +### Usage Protocol +- **Primary Method**:Use `UiColors` from the design system for specific brand accents. +- **Naming Matching**: If an exact color is missing, use the closest existing semantic color (e.g., use `UiColors.mutedForeground` instead of a hardcoded grey). + +```dart +// โŒ ANTI-PATTERN: Hardcoded color +Container(color: Color(0xFF1A2234)) + +// โœ… CORRECT: Design system token +Container(color: UiColors.background) +``` + +## 5. Typography Usage Rules + +Custom `TextStyle` definitions in feature packages are **STRICTLY PROHIBITED**. + +### Usage Protocol +- Use `UiTypography` from the design system for specific brand accents. + +```dart +// โŒ ANTI-PATTERN: Custom TextStyle +Text('Hello', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)) + +// โœ… CORRECT: Design system typography +Text('Hello', style: UiTypography.display1m) +``` + +## 6. Icons Usage Rules + +Feature packages **MUST NOT** import icon libraries (like `lucide_icons`) directly. They should use the icons exposed via `UiIcons`. + +- **Standardization**: Ensure the same icon is used for the same action across all features (e.g., always use `UiIcons.chevronLeft` for navigation). +- **Additions**: New icons must be added to the design system (only using the typedef _IconLib = LucideIcons or typedef _IconLib2 = FontAwesomeIcons; and nothing else) first to ensure they follow the project's stroke weight and sizing standards. + +## 7. UI Constants & Layout Rules + +Hardcoded padding, margins, and radius values are **PROHIBITED**. + +- **Spacing**: Use `UiConstants.spacing` multiplied by tokens (e.g., `S`, `M`, `L`). +- **Border Radius**: Use `UiConstants.borderRadius`. +- **Elevation**: Use `UiConstants.elevation`. + +```dart +// โœ… CORRECT: Spacing and Radius constants +Padding( + padding: EdgeInsets.all(UiConstants.spacingL), + child: Container( + borderRadius: BorderRadius.circular(UiConstants.radiusM), + ), +) +``` + +## 8. Common Smart Widgets Guidelines + +The design system provides "Smart Widgets" โ€“ these are high-level UI components that encapsulate both styling and standard behavior. + +- **Standard Widgets**: Prefer standard Flutter Material widgets (e.g., `ElevatedButton`) but styled via the central theme. +- **Custom Components**: Use `design_system` widgets for non-standard elements or wisgets that has similar design across various features, if provided. +- **Composition**: Prefer composing standard widgets over creating deep inheritance hierarchies in features. + +## 9. Theme Configuration & Usage + +Applications (`apps/mobile/apps/`) must initialize the theme once in the root `MaterialApp`. + +```dart +MaterialApp.router( + theme: StaffTheme.light, // Mandatory: Consumption of centralized theme + // ... +) +``` +**No application-level theme customization is allowed.** + +## 10. Feature Development Workflow (POC โ†’ Themed) + +To bridge the gap between rapid prototyping (POCs) and production-grade code, developers must follow this three-step workflow: + +1. **Step 1: Structural Implementation**: Implement the UI logic and layout **exactly matching the POC**. Hardcoded values from the POC are acceptable in this transient state to ensure visual parity. +2. **Step 2: Architecture Refactor**: Immediately refactor the code to: + - Follow clean architecture principles from `apps/mobile/docs/00-agent-development-rules.md` and `01-architecture-principles.md` + - Move business logic from widgets to BLoCs and use cases + - Implement proper repository pattern with Data Connect + - Use dependency injection via Flutter Modular +3. **Step 3: Design System Integration**: Immediately refactor UI to consume design system primitives: + - Replace hex codes with `UiColors` + - Replace manual `TextStyle` with `UiTypography` + - Replace hardcoded padding/radius with `UiConstants` + - Upgrade icons to design system versions + - Use `ThemeData` from `design_system` instead of local theme overrides + +## 11. Anti-Patterns & Common Mistakes + +- **"Magic Numbers"**: Hardcoding `EdgeInsets.all(12.0)` instead of using design system constants. +- **Local Themes**: Using `Theme(data: ...)` to override colors for a specific section of a page. +- **Hex Hunting**: Copy-pasting hex codes from Figma or POCs into feature code. +- **Package Bypassing**: Importing `package:flutter/material.dart` and ignoring `package:design_system`. +- **Stateful Pages**: Pages with complex state logic instead of delegating to BLoCs. +- **Direct Data Queries**: Features querying Data Connect directly instead of through repositories. +- **Global State**: Using global variables for session/auth instead of `SessionStore` + `SessionListener`. +- **Hardcoded Routes**: Using `Navigator.push(context, MaterialPageRoute(...))` instead of Modular. +- **Feature Coupling**: Importing one feature package from another instead of using domain-level interfaces. + +## 12. Enforcement & Review Checklist + +Before any UI code is merged, it must pass this checklist: + +### Design System Compliance +1. [ ] No hardcoded `Color(...)` or `0xFF...` in the feature package. +2. [ ] No custom `TextStyle(...)` definitions. +3. [ ] All spacing/padding/radius uses `UiConstants`. +4. [ ] All icons are consumed from the approved design system source. +5. [ ] The feature relies on the global `ThemeData` and does not provide local overrides. +6. [ ] The layout matches the POC visual intent while using design system primitives. + +### Architecture Compliance +7. [ ] No direct Data Connect queries in widgets; all data access via repositories. +8. [ ] BLoCs handle all non-trivial state logic; pages are mostly stateless. +9. [ ] Session/auth accessed via `SessionStore` not global state. +10. [ ] Navigation uses Flutter Modular named routes. +11. [ ] Features don't import other feature packages directly. +12. [ ] All business logic in use cases, not BLoCs or widgets. +13. [ ] Repositories properly implement error handling and mapping. +14. [ ] Doc comments present on all public classes and methods. diff --git a/docs/MOBILE/03-data-connect-connectors-pattern.md b/docs/MOBILE/03-data-connect-connectors-pattern.md new file mode 100644 index 00000000..4f5c353a --- /dev/null +++ b/docs/MOBILE/03-data-connect-connectors-pattern.md @@ -0,0 +1,285 @@ +# Data Connect Connectors Pattern + +## Overview + +This document describes the **Data Connect Connectors** pattern implemented in the KROW mobile app. This pattern centralizes all backend query logic by mirroring backend connector structure in the mobile data layer. + +## Problem Statement + +**Without Connectors Pattern:** +- Each feature creates its own repository implementation +- Multiple features query the same backend connector โ†’ duplication +- When backend queries change, updates needed in multiple places +- No reusability across features + +**Example Problem:** +``` +staff_main/ + โ””โ”€โ”€ data/repositories/profile_completion_repository_impl.dart โ† queries staff connector +profile/ + โ””โ”€โ”€ data/repositories/profile_repository_impl.dart โ† also queries staff connector +onboarding/ + โ””โ”€โ”€ data/repositories/personal_info_repository_impl.dart โ† also queries staff connector +``` + +## Solution: Connectors in Data Connect Package + +All backend connector queries are implemented once in a centralized location, following the backend structure. + +### Structure + +``` +apps/mobile/packages/data_connect/lib/src/connectors/ +โ”œโ”€โ”€ staff/ +โ”‚ โ”œโ”€โ”€ domain/ +โ”‚ โ”‚ โ”œโ”€โ”€ repositories/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ staff_connector_repository.dart (interface) +โ”‚ โ”‚ โ””โ”€โ”€ usecases/ +โ”‚ โ”‚ โ””โ”€โ”€ get_profile_completion_usecase.dart +โ”‚ โ””โ”€โ”€ data/ +โ”‚ โ””โ”€โ”€ repositories/ +โ”‚ โ””โ”€โ”€ staff_connector_repository_impl.dart (implementation) +โ”œโ”€โ”€ order/ +โ”œโ”€โ”€ user/ +โ”œโ”€โ”€ emergency_contact/ +โ””โ”€โ”€ ... +``` + +**Maps to backend structure:** +``` +backend/dataconnect/connector/ +โ”œโ”€โ”€ staff/ +โ”œโ”€โ”€ order/ +โ”œโ”€โ”€ user/ +โ”œโ”€โ”€ emergency_contact/ +โ””โ”€โ”€ ... +``` + +## Clean Architecture Layers + +Each connector follows Clean Architecture with three layers: + +### Domain Layer (`connectors/{name}/domain/`) + +**Repository Interface:** +```dart +// staff_connector_repository.dart +abstract interface class StaffConnectorRepository { + Future getProfileCompletion(); + Future getStaffById(String id); + // ... more queries +} +``` + +**Use Cases:** +```dart +// get_profile_completion_usecase.dart +class GetProfileCompletionUseCase { + GetProfileCompletionUseCase({required StaffConnectorRepository repository}); + Future call() => _repository.getProfileCompletion(); +} +``` + +**Characteristics:** +- Pure Dart, no framework dependencies +- Stable, business-focused contracts +- One interface per connector +- One use case per query or related query group + +### Data Layer (`connectors/{name}/data/`) + +**Repository Implementation:** +```dart +// staff_connector_repository_impl.dart +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + return _isProfileComplete(response); + }); + } +} +``` + +**Characteristics:** +- Implements domain repository interface +- Uses `DataConnectService` to execute queries +- Maps backend response types to domain models +- Contains mapping/transformation logic only +- Handles type safety with generated Data Connect types + +## Integration Pattern + +### Step 1: Feature Needs Data + +Feature (e.g., `staff_main`) needs profile completion status. + +### Step 2: Use Connector Repository + +Instead of creating a local repository, feature uses the connector: + +```dart +// staff_main_module.dart +class StaffMainModule extends Module { + @override + void binds(Injector i) { + // Register connector repository from data_connect + i.addSingleton( + StaffConnectorRepositoryImpl.new, + ); + + // Feature creates its own use case wrapper if needed + i.addSingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + // BLoC uses the use case + i.addSingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); + } +} +``` + +### Step 3: BLoC Uses It + +```dart +class StaffMainCubit extends Cubit { + StaffMainCubit({required GetProfileCompletionUseCase usecase}) { + _loadProfileCompletion(); + } + + Future _loadProfileCompletion() async { + final isComplete = await _getProfileCompletionUsecase(); + emit(state.copyWith(isProfileComplete: isComplete)); + } +} +``` + +## Export Pattern + +Connectors are exported from `krow_data_connect` for easy access: + +```dart +// lib/krow_data_connect.dart +export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart'; +export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart'; +export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; +``` + +**Features import:** +```dart +import 'package:krow_data_connect/krow_data_connect.dart'; +``` + +## Adding New Queries to Existing Connector + +When backend adds `getStaffById()` query to staff connector: + +1. **Add to interface:** + ```dart + abstract interface class StaffConnectorRepository { + Future getStaffById(String id); + } + ``` + +2. **Implement in repository:** + ```dart + @override + Future getStaffById(String id) async { + return _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } + ``` + +3. **Use in features:** + ```dart + // Any feature can now use it + final staff = await i.get().getStaffById(id); + ``` + +## Adding New Connector + +When backend adds new connector (e.g., `order`): + +1. Create directory: `apps/mobile/packages/data_connect/lib/src/connectors/order/` + +2. Create domain layer with repository interface and use cases + +3. Create data layer with repository implementation + +4. Export from `krow_data_connect.dart` + +5. Features can immediately start using it + +## Benefits + +โœ… **No Duplication** - Query implemented once, used by many features +โœ… **Single Source of Truth** - Backend change โ†’ update one place +โœ… **Clean Separation** - Connector logic separate from feature logic +โœ… **Reusability** - Any feature can request any connector data +โœ… **Testability** - Mock the connector repo to test features +โœ… **Scalability** - Easy to add new connectors as backend grows +โœ… **Mirrors Backend** - Mobile structure mirrors backend structure + +## Anti-Patterns + +โŒ **DON'T**: Implement query logic in feature repository +โŒ **DON'T**: Duplicate queries across multiple repositories +โŒ **DON'T**: Put mapping logic in features +โŒ **DON'T**: Call `DataConnectService` directly from BLoCs + +**DO**: Use connector repositories through use cases in features. + +## Current Implementation + +### Staff Connector + +**Location**: `apps/mobile/packages/data_connect/lib/src/connectors/staff/` + +**Available Queries**: +- `getProfileCompletion()` - Returns bool indicating if profile is complete + - Checks: personal info, emergency contacts, tax forms, experience (skills/industries) + +**Used By**: +- `staff_main` - Guards bottom nav items requiring profile completion + +**Backend Queries Used**: +- `backend/dataconnect/connector/staff/queries/profile_completion.gql` + +### Shifts Connector + +**Location**: `apps/mobile/packages/data_connect/lib/src/connectors/shifts/` + +**Available Queries**: +- `listShiftRolesByVendorId()` - Fetches shifts for a specific vendor with status mapping +- `applyForShifts()` - Handles shift application with error tracking + +**Backend Queries Used**: +- `backend/dataconnect/connector/shifts/queries/list_shift_roles_by_vendor.gql` +- `backend/dataconnect/connector/shifts/mutations/apply_for_shifts.gql` + +## Future Expansion + +As the app grows, additional connectors will be added: +- `order_connector_repository` (queries from `backend/dataconnect/connector/order/`) +- `user_connector_repository` (queries from `backend/dataconnect/connector/user/`) +- `emergency_contact_connector_repository` (queries from `backend/dataconnect/connector/emergencyContact/`) +- etc. + +Each following the same Clean Architecture pattern implemented for Staff Connector. diff --git a/docs/MOBILE/04-use-case-completion-audit.md b/docs/MOBILE/04-use-case-completion-audit.md new file mode 100644 index 00000000..e0fd8ecc --- /dev/null +++ b/docs/MOBILE/04-use-case-completion-audit.md @@ -0,0 +1,362 @@ +# ๐Ÿ“Š Use Case Completion Audit + +**Generated:** 2026-02-23 +**Auditor Role:** System Analyst / Flutter Architect +**Source of Truth:** `docs/ARCHITECTURE/client-mobile-application/use-case.md`, `docs/ARCHITECTURE/staff-mobile-application/use-case.md`, `docs/ARCHITECTURE/system-bible.md`, `docs/ARCHITECTURE/architecture.md` +**Codebase Checked:** `apps/mobile/packages/features/` (real app) vs `apps/mobile/prototypes/` (prototypes) + +--- + +## ๐Ÿ“Œ How to Read This Document + +| Symbol | Meaning | +|:---:|:--- | +| โœ… | Fully implemented in the real app | +| ๐ŸŸก | Partially implemented โ€” UI or domain exists but logic is incomplete | +| โŒ | Defined in docs but entirely missing in the real app | +| โš ๏ธ | Exists in prototype but has **not** been migrated to the real app | +| ๐Ÿšซ | Exists in real app code but is **not** documented in use cases | + +--- + +## ๐Ÿง‘โ€๐Ÿ’ผ CLIENT APP + +### Feature Module: `authentication` + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 1.1 Initial Startup & Auth Check | System checks session on launch | โœ… | โœ… | โœ… Completed | `client_get_started_page.dart` handles auth routing via Modular. | +| 1.1 Initial Startup & Auth Check | Route to Home if authenticated | โœ… | โœ… | โœ… Completed | Navigation guard implemented in auth module. | +| 1.1 Initial Startup & Auth Check | Route to Get Started if unauthenticated | โœ… | โœ… | โœ… Completed | `client_intro_page.dart` + `client_get_started_page.dart` both exist. | +| 1.2 Register Business Account | Enter company name & industry | โœ… | โœ… | โœ… Completed | `client_sign_up_page.dart` fully implemented. | +| 1.2 Register Business Account | Enter contact info & password | โœ… | โœ… | โœ… Completed | Real app BLoC-backed form with validation. | +| 1.2 Register Business Account | Registration success โ†’ Main App | โœ… | โœ… | โœ… Completed | Post-registration redirection intact. | +| 1.3 Business Sign In | Enter email & password | โœ… | โœ… | โœ… Completed | `client_sign_in_page.dart` fully implemented. | +| 1.3 Business Sign In | System validates credentials | โœ… | โœ… | โœ… Completed | Auth BLoC with error states present. | +| 1.3 Business Sign In | Grant access to dashboard | โœ… | โœ… | โœ… Completed | Redirects to `client_main` shell on success. | + +--- + +### Feature Module: `orders` (Order Management) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 2.1 Rapid Order | Tap RAPID โ†’ Select Role โ†’ Set Qty โ†’ Post | โœ… | โœ… | ๐ŸŸก Partial | `rapid_order_page.dart` & `RapidOrderBloc` exist with full view. Voice recognition is **simulated** (UI only, no actual voice API). | +| 2.2 Scheduled Orders โ€” One-Time | Create single shift (date, time, role, location) | โœ… | โœ… | โœ… Completed | `one_time_order_page.dart` fully implemented with BLoC. | +| 2.2 Scheduled Orders โ€” Recurring | Create recurring shifts (e.g., every Monday) | โœ… | โœ… | โœ… Completed | `recurring_order_page.dart` fully implemented. | +| 2.2 Scheduled Orders โ€” Permanent | Long-term staffing placement | โœ… | โœ… | โœ… Completed | `permanent_order_page.dart` fully implemented. | +| 2.2 Scheduled Orders | Review cost before posting | โœ… | โœ… | ๐ŸŸก Partial | Order summary shown, but real-time cost calculation depends on backend. | +| View & Browse Active Orders | Search & toggle between weeks to view orders | โœ… | โœ… | ๐Ÿšซ Completed | `view_orders_page.dart` exists with `ViewOrderCard`. Added `eventName` visibility. | +| Modify Posted Orders | Refine staffing needs post-publish | โœ… | โœ… | ๐Ÿšซ Completed | `OrderEditSheet` handles position updates and entire order cancellation flow. | + +--- + +### Feature Module: `client_coverage` (Operations & Workforce Management) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 3.1 Monitor Today's Coverage | View coverage tab | โœ… | โœ… | โœ… Completed | `coverage_page.dart` exists with coverage header and shift list. | +| 3.1 Monitor Today's Coverage | View percentage filled | โœ… | โœ… | โœ… Completed | `coverage_header.dart` shows fill rate. | +| 3.1 Monitor Today's Coverage | Identify open gaps | โœ… | โœ… | โœ… Completed | Open/filled shift list in `coverage_shift_list.dart`. | +| 3.1 Monitor Today's Coverage | Re-post unfilled shifts | โœ… | โœ… | ๐Ÿšซ Completed | Action added to shift header on Coverage page. | +| 3.2 Live Activity Tracking | Real-time feed of worker clock-ins | โœ… | โœ… | โœ… Completed | `live_activity_widget.dart` wired to Data Connect. | +| 3.3 Verify Worker Attire | Select active shift โ†’ Select worker โ†’ Check attire | โœ… | โœ… | โœ… Completed | Action added to coverage view; workers can be verified in real-time. | +| 3.4 Review & Approve Timesheets | Navigate to Timesheets section | โœ… | โœ… | โœ… Completed | Implemented `TimesheetsPage` in billing module for approval workflow. | +| 3.4 Review & Approve Timesheets | Review actual vs. scheduled hours | โœ… | โœ… | โœ… Completed | Viewable in the timesheet approval card. | +| 3.4 Review & Approve Timesheets | Tap Approve / Dispute | โœ… | โœ… | โœ… Completed | Approve/Decline actions implemented in `TimesheetsPage`. | + +--- + +### Feature Module: `reports` (Reports & Analytics) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 4.1 Business Intelligence Reporting | Daily Ops Report | โœ… | โœ… | โœ… Completed | `daily_ops_report_page.dart` fully implemented. | +| 4.1 Business Intelligence Reporting | Spend Report | โœ… | โœ… | โœ… Completed | `spend_report_page.dart` fully implemented. | +| 4.1 Business Intelligence Reporting | Forecast Report | โœ… | โœ… | โœ… Completed | `forecast_report_page.dart` fully implemented. | +| 4.1 Business Intelligence Reporting | Performance Report | โœ… | โœ… | โœ… Completed | `performance_report_page.dart` fully implemented. | +| 4.1 Business Intelligence Reporting | No-Show Report | โœ… | โœ… | โœ… Completed | `no_show_report_page.dart` fully implemented. | +| 4.1 Business Intelligence Reporting | Coverage Report | โœ… | โœ… | โœ… Completed | `coverage_report_page.dart` fully implemented. | + +--- + +### Feature Module: `billing` (Billing & Administration) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 5.1 Financial Management | View current balance | โœ… | โœ… | โœ… Completed | `billing_page.dart` shows `currentBill` and period billing. | +| 5.1 Financial Management | View pending invoices | โœ… | โœ… | โœ… Completed | `PendingInvoicesSection` widget fully wired via `BillingBloc`. | +| 5.1 Financial Management | Download past invoices | โœ… | โœ… | ๐ŸŸก Partial | `InvoiceHistorySection` exists but download action is not confirmed wired to a real download handler. | +| 5.1 Financial Management | Update credit card / ACH info | โœ… | โœ… | ๐ŸŸก Partial | `PaymentMethodCard` widget exists but update/add payment method form is not present in real app pages. | + +--- + +### Feature Module: `hubs` (Manage Business Locations) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 5.2 Manage Business Locations | View list of client hubs | โœ… | โœ… | โœ… Completed | `client_hubs_page.dart` fully implemented. | +| 5.2 Manage Business Locations | Add new hub (location + address) | โœ… | โœ… | โœ… Completed | `edit_hub_page.dart` serves create + edit. | +| 5.2 Manage Business Locations | Edit existing hub | โœ… | โœ… | โœ… Completed | `edit_hub_page.dart` + `hub_details_page.dart` both present. | + +--- + +### Feature Module: `settings` (Profile & Settings) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 5.3 Profile & Settings Management | Edit personal contact info | โœ… | โœ… | โœ… Completed | Implemented `EditProfilePage` in settings module. | +| 5.1 System Settings | Toggle notification preferences | โœ… | โœ… | โœ… Completed | Implemented notification preference toggles for Push, Email, and SMS. | + +--- + +### Feature Module: `home` (Home Tab) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| Home โ€” Create Order entry point | Select order type and launch flow | โœ… | โœ… | โœ… Completed | `shift_order_form_sheet.dart` (47KB) orchestrates all order types from the home tab. | +| Home โ€” Quick Actions Widget | Display quick action shortcuts | โœ… | โœ… | โœ… Completed | `actions_widget.dart` present. | +| Home โ€” Navigate to Settings | Settings shortcut from Home | โœ… | โœ… | โœ… Completed | `client_home_header.dart` has settings navigation. | +| Home โ€” Navigate to Hubs | Hub shortcut from Home | โœ… | โœ… | โœ… Completed | `actions_widget.dart` navigates to hubs. | +| Customizable Home Dashboard | Reorderable widgets for client overview | โŒ | โœ… | ๐Ÿšซ Completed | `draggable_widget_wrapper.dart` + `reorder_widget.dart` + `dashboard_widget_builder.dart` exist in real app. | +| Operational Spend Snapshot | View periodic spend summary on home | โŒ | โœ… | ๐Ÿšซ Completed | `spending_widget.dart` implemented on home dashboard. | +| Coverage Summary Widget | Quick view of fill rates on home | โŒ | โœ… | ๐Ÿšซ Completed | `coverage_dashboard.dart` widget embedded on home. | +| View Workers Directory | Manage and view staff list | โœ… | โŒ | โš ๏ธ Prototype Only | `client_workers_screen.dart` in prototype. No `workers` feature package in real app. | + +--- +--- + +## ๐Ÿ‘ท STAFF APP + +### Feature Module: `authentication` + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 1.1 App Initialization | Check auth token on startup | โœ… | โœ… | โœ… Completed | `intro_page.dart` + `get_started_page.dart` handle routing. | +| 1.1 App Initialization | Route to Home if valid | โœ… | โœ… | โœ… Completed | Navigation guard in `staff_authentication_module.dart`. | +| 1.1 App Initialization | Route to Get Started if invalid | โœ… | โœ… | โœ… Completed | Implemented. | +| 1.2 Onboarding & Registration | Enter phone number | โœ… | โœ… | โœ… Completed | `phone_verification_page.dart` fully implemented. | +| 1.2 Onboarding & Registration | Receive & verify SMS OTP | โœ… | โœ… | โœ… Completed | OTP verification BLoC wired to real auth backend. | +| 1.2 Onboarding & Registration | Check if profile exists | โœ… | โœ… | โœ… Completed | Routing logic in auth module checks profile completion. | +| 1.2 Onboarding & Registration | Profile Setup Wizard โ€” Personal Info | โœ… | โœ… | โœ… Completed | `profile_info` section: `personal_info_page.dart` fully implemented. | +| 1.2 Onboarding & Registration | Profile Setup Wizard โ€” Role & Experience | โœ… | โœ… | โœ… Completed | `experience` section: `experience_page.dart` implemented. | +| 1.2 Onboarding & Registration | Profile Setup Wizard โ€” Attire Sizes | โœ… | โœ… | โœ… Completed | `attire` section: `attire_page.dart` implemented via `profile_sections/onboarding/attire`. | +| 1.2 Onboarding & Registration | Enter Main App after profile setup | โœ… | โœ… | โœ… Completed | Wizard completion routes to staff main shell. | +| Emergency Contact Management | Setup primary/secondary emergency contacts | โœ… | โœ… | ๐Ÿšซ Completed | `emergency_contact_screen.dart` in both prototype and real app. | + +--- + +### Feature Module: `home` (Job Discovery) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 2.1 Browse & Filter Jobs | View available jobs list | โœ… | โœ… | โœ… Completed | `find_shifts_tab.dart` in `shifts` renders all available jobs. Fully localized via `core_localization`. | +| 2.1 Browse & Filter Jobs | Filter by Role | โœ… | โœ… | ๐ŸŸก Partial | Search by title/location/client name is implemented. Filter by **role** (as in job category) uses type-based tabs (one-day, multi-day, long-term) rather than role selection. | +| 2.1 Browse & Filter Jobs | Filter by Distance | โœ… | โœ… | โœ… Completed | Implemented Geolocator-based radius filtering (5-100 miles). Fixed bug where filter was bypassed for 'All' tab. | +| 2.1 Browse & Filter Jobs | View job card details (Pay, Location, Requirements) | โœ… | โœ… | โœ… Completed | `MyShiftCard` + `shift_details_page.dart` with full shift info. Added `endDate` support for multi-day shifts. | +| 2.3 Set Availability | Select dates/times โ†’ Save preferences | โœ… | โœ… | โœ… Completed | `availability_page.dart` fully implemented with `AvailabilityBloc`. | +| Upcoming Shift Quick-Link | Direct access to next shift from home | โœ… | โœ… | ๐Ÿšซ Completed | `worker_home_page.dart` shows upcoming shifts banner. | + +--- + +### Feature Module: `shifts` (Find Shifts + My Schedule) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 2.2 Claim Open Shift | Tap "Claim Shift" from Job Details | โœ… | โœ… | ๐ŸŸก Partial | `AcceptShiftEvent` in `ShiftsBloc` fired correctly. Backend check wired via `ShiftDetailsBloc`. | +| 2.2 Claim Open Shift | System validates eligibility (certs, conflicts) | โœ… | โœ… | ๐Ÿšซ Completed | Intercept logic added to redirect to Certificates if failure message indicates ELIGIBILITY or COMPLIANCE. | +| 2.2 Claim Open Shift | Prompt to Upload Compliance Docs if missing | โœ… | โœ… | ๐Ÿšซ Completed | Redirect dialog implemented in `ShiftDetailsPage` on eligibility failure. | +| 3.1 View Schedule | View list of claimed shifts (My Shifts tab) | โœ… | โœ… | โœ… Completed | `my_shifts_tab.dart` fully implemented with shift cards. | +| 3.1 View Schedule | View Shift Details | โœ… | โœ… | โœ… Completed | `shift_details_page.dart` with header, location map, schedule summary, stats. Corrected weekday mapping and added `endDate`. | +| Completed Shift History | View past worked shifts and earnings | โŒ | โœ… | ๐Ÿšซ Completed | `history_shifts_tab.dart` fully wired in `shifts_page.dart`. | +| Multi-day Schedule View | Visual grouping of spanned shift dates | โŒ | โœ… | ๐Ÿšซ Completed | Multi-day grouping logic in `_groupMultiDayShifts()` supports `endDate`. | + +--- + +### Feature Module: `clock_in` (Shift Execution) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 3.2 GPS-Verified Clock In | Navigate to Clock In tab | โœ… | โœ… | โœ… Completed | `clock_in_page.dart` is a dedicated tab. | +| 3.2 GPS-Verified Clock In | System checks GPS location vs job site | โœ… | โœ… | โœ… Completed | GPS radius enforced (500m). `SwipeToCheckIn` is disabled until within range. | +| 3.2 GPS-Verified Clock In | "Swipe to Clock In" active when On Site | โœ… | โœ… | โœ… Completed | `SwipeToCheckIn` widget activates when time window is valid. | +| 3.2 GPS-Verified Clock In | Show error if Off Site | โœ… | โœ… | โœ… Completed | UX improved with real-time distance warning and disabled check-in button when too far. | +| 3.2 GPS-Verified Clock In | Contactless NFC Clock-In mode | โŒ | โœ… | ๐Ÿšซ Completed | `_showNFCDialog()` and NFC check-in logic implemented. | +| 3.3 Submit Timesheet | Swipe to Clock Out | โœ… | โœ… | โœ… Completed | `SwipeToCheckIn` toggles to clock-out mode. `CheckOutRequested` event fires. | +| 3.3 Submit Timesheet | Confirm total hours & break times | โœ… | โœ… | โœ… Completed | `LunchBreakDialog` handles break confirmation. Attire photo captured during clock-in. | +| 3.3 Submit Timesheet | Submit timesheet for client approval | โœ… | โœ… | โœ… Completed | Implemented "Submit for Approval" action on completed `MyShiftCard`. | + +--- + +### Feature Module: `payments` (Financial Management) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 4.1 Track Earnings | View Pending Pay (unpaid earnings) | โœ… | โœ… | โœ… Completed | `PendingPayCard` in `payments_page.dart` shows `pendingEarnings`. | +| 4.1 Track Earnings | View Total Earned (paid earnings) | โœ… | โœ… | โœ… Completed | `PaymentsLoaded.summary.totalEarnings` displayed on header. | +| 4.1 Track Earnings | View Payment History | โœ… | โœ… | โœ… Completed | `PaymentHistoryItem` list rendered from `state.history`. | +| 4.2 Request Early Pay | Tap "Request Early Pay" | โœ… | โœ… | โœ… Completed | `PendingPayCard` has `onCashOut` โ†’ navigates to `/early-pay`. | +| 4.2 Request Early Pay | Select amount to withdraw | โœ… | โœ… | โœ… Completed | Implemented `EarlyPayPage` for selecting cash-out amount. | +| 4.2 Request Early Pay | Confirm transfer fee | โœ… | โœ… | โœ… Completed | Fee confirmation included in `EarlyPayPage`. | +| 4.2 Request Early Pay | Funds transferred to bank account | โœ… | โœ… | โœ… Completed | Request submission flow functional. | + +--- + +### Feature Module: `profile` + `profile_sections` (Profile & Compliance) + +| Use Case | Sub-Use Case | Prototype | Real App | Status | Notes | +|:---|:---|:---:|:---:|:---:|:---| +| 5.1 Manage Compliance Documents | Navigate to Compliance Menu | โœ… | โœ… | โœ… Completed | `ComplianceSection` in `staff_profile_page.dart` links to sub-modules. | +| 5.1 Manage Compliance Documents | Upload Certificates (take photo / submit) | โœ… | โœ… | โœ… Completed | `certificates_page.dart` + `certificate_upload_modal.dart` fully implemented. | +| 5.1 Manage Compliance Documents | View/Manage Identity Documents | โœ… | โœ… | โœ… Completed | `documents_page.dart` with `documents_progress_card.dart`. | +| 5.2 Manage Tax Forms | Complete W-4 digitally & submit | โœ… | โœ… | โœ… Completed | `form_w4_page.dart` + `FormW4Cubit` fully implemented. | +| 5.2 Manage Tax Forms | Complete I-9 digitally & submit | โœ… | โœ… | โœ… Completed | `form_i9_page.dart` + `FormI9Cubit` fully implemented. | +| 5.3 Krow University Training | Navigate to Krow University | โœ… | โŒ | โŒ Not Implemented | `krow_university_screen.dart` exists **only** in prototype. No `krow_university` or training package in real app feature modules. | +| 5.3 Krow University Training | Select Module โ†’ Watch Video / Take Quiz | โœ… | โŒ | โš ๏ธ Prototype Only | Fully prototyped (courses, categories, XP tracking). Not migrated at all. | +| 5.3 Krow University Training | Earn Badge | โœ… | โŒ | โš ๏ธ Prototype Only | Prototype only. | +| 5.4 Account Settings | Update Bank Details | โœ… | โœ… | โœ… Completed | `bank_account_page.dart` + `BankAccountCubit` in `profile_sections/finances/staff_bank_account`. | +| 5.4 Account Settings | View Benefits | โœ… | โŒ | โš ๏ธ Prototype Only | `benefits_screen.dart` exists only in prototype. No `benefits` package in real app. | +| 5.4 Account Settings | Access Support / FAQs | โœ… | โœ… | โœ… Completed | `faqs_page.dart` with `FAQsBloc` and search in `profile_sections/support/faqs`. | +| Timecard & Hours Log | Audit log of clock-in/out events | โœ… | โœ… | ๐Ÿšซ Completed | `time_card_page.dart` in `profile_sections/finances/time_card`. | +| Privacy & Security Controls | Manage account data and app permissions | โœ… | โœ… | ๐Ÿšซ Completed | `privacy_security_page.dart` in `support/privacy_security`. | +| Worker Leaderboard | Competitive performance tracking | โœ… | โŒ | โš ๏ธ Prototype Only | `leaderboard_screen.dart` in prototype. No real app equivalent. | +| In-App Support Chat | Direct messaging with support team | โœ… | โŒ | โš ๏ธ Prototype Only | `messages_screen.dart` in prototype. Not in real app. | + +--- +--- + +## 1๏ธโƒฃ Summary Statistics + +### Client App + +| Metric | Count | +|:---|:---:| +| **Total documented use cases (sub-use cases)** | 38 | +| โœ… Fully Implemented | 21 | +| ๐ŸŸก Partially Implemented | 6 | +| โŒ Not Implemented | 1 | +| โš ๏ธ Prototype Only (not migrated) | 1 | +| ๐Ÿšซ Completed (Extra) | 6 | + +**Client App Completion Rate (fully implemented):** ~76% +**Client App Implementation Coverage (completed + partial):** ~94% + +--- + +### Staff App + +| Metric | Count | +|:---|:---:| +| **Total documented use cases (sub-use cases)** | 45 | +| โœ… Fully Implemented | 23 | +| ๐ŸŸก Partially Implemented | 6 | +| โŒ Not Implemented | 2 | +| โš ๏ธ Prototype Only (not migrated) | 6 | +| ๐Ÿšซ Completed (Extra) | 8 | + +**Staff App Completion Rate (fully implemented):** ~71% +**Staff App Implementation Coverage (completed + partial):** ~85% + +--- + +## 2๏ธโƒฃ Critical Gaps + +The following are **high-priority missing flows** that block core business value: + +1. **Staff: Krow University & Benefits** + Several modules exist in the prototype but are missing in the real app, including training Modules, XP tracking, and Benefits views. + +--- + +2. **Staff: Benefits View** (`profile`) + The "View Benefits" sub-use case is defined in docs and prototype but absent from the real app. + +--- + +## 3๏ธโƒฃ Architecture Drift + +The following inconsistencies between the system design documents and the actual real app implementation were identified: + +--- + +### AD-01: GPS Clock-In Enforcement vs. Time-Window Gate +**Docs Say:** `system-bible.md` ยง10 โ€” *"No GPS, No Pay: A clock-in event MUST have valid geolocation data attached."* +**Reality:** โœ… **Resolved**. The real `clock_in_page.dart` now enforces a **500m GPS radius check**. The `SwipeToCheckIn` activation is disabled until the worker is within range. + +--- + +### AD-02: Compliance Gate on Shift Claim +**Docs Say:** `use-case.md` (Staff) ยง2.2 โ€” *"System validates eligibility (Certificates, Conflicts). If missing requirements, system prompts to Upload Compliance Docs."* +**Reality:** โœ… **Resolved**. Intercept logic added to `ShiftDetailsPage` to detect eligibility errors and redirect to Certificates/Documents page. + +--- + +### AD-03: "Split Brain" Logic Risk โ€” Client-Side Calculations +**Docs Say:** `system-bible.md` ยง7 โ€” *"Business logic must live in the Backend, NOT duplicated in the mobile apps."* +**Reality:** `_groupMultiDayShifts()` in `find_shifts_tab.dart` and cost calculation logic in `shift_order_form_sheet.dart` (47KB file) perform grouping/calculation logic on the client. This is a drift from the single-source-of-truth principle. The `shift_order_form_sheet.dart` is also an architectural risk โ€” a 47KB monolithic widget file suggests the order creation logic has not been cleanly separated into BLoC/domain layers for all flows. + +--- + +### AD-04: Timesheet Lifecycle Disconnected +**Docs Say:** `architecture.md` ยง3 & `system-bible.md` ยง5 โ€” Approved timesheets trigger payment scheduling. The cycle is: `Clock Out โ†’ Timesheet โ†’ Client Approve โ†’ Payment Processed`. +**Reality:** โœ… **Resolved**. Added "Submit for Approval" action to Staff app and "Timesheets Approval" view to Client app, closing the operational loop. + +--- + +### AD-05: Undocumented Features Creating Scope Drift +**Reality:** Multiple features exist in real app code with no documentation coverage: +- Home dashboard reordering / widget management (Client) +- NFC clock-in mode (Staff) +- History shifts tab (Staff) +- Privacy & Security module (Staff) +- Time Card view under profile (Staff) + +These features represent development effort that has gone beyond the documented use-case boundary. Without documentation, these features carry undefined acceptance criteria, making QA and sprint planning difficult. + +--- + +### AD-06: `client_workers_screen` (View Workers) โ€” Missing Migration +**Docs Show:** `architecture.md` ยงA and the use-case diagram reference `ViewWorkers` from the Home tab. +**Reality:** `client_workers_screen.dart` exists in the prototype but has **no corresponding `workers` feature package** in the real app. This breaks a documented Home Tab flow. + +--- + +### AD-07: Benefits Feature โ€” Defined in Docs, Absent in Real App +**Docs Say:** `use-case.md` (Staff) ยง5.4 โ€” *"View Benefits"* is a sub-use case. +**Reality:** `benefits_screen.dart` is fully built in the prototype (insurance, earned time off, etc.) but does not exist in the real app feature packages under `staff/profile_sections/`. + +--- + +## 4๏ธโƒฃ Orphan Prototype Screens (Not Migrated) + +The following screens exist **only** in the prototypes and have no real-app equivalent: + +### Client Prototype +| Screen | Path | +|:---|:---| +| Workers List | `client/client_workers_screen.dart` | +| Verify Worker Attire | `client/verify_worker_attire_screen.dart` | + +### Staff Prototype +| Screen | Path | +|:---|:---| +| Benefits | `worker/benefits_screen.dart` | +| Krow University | `worker/worker_profile/level_up/krow_university_screen.dart` | +| Leaderboard | `worker/worker_profile/level_up/leaderboard_screen.dart` | +| Training Modules | `worker/worker_profile/level_up/trainings_screen.dart` | +| In-App Messages | `worker/worker_profile/support/messages_screen.dart` | + +--- + +## 5๏ธโƒฃ Recommendations for Sprint Planning + +### Sprint Focus Areas (Priority Order) + +| ๐ŸŸ  P2 | Migrate Krow University training module from prototype | Large | +| ๐ŸŸ  P2 | Migrate Benefits view from prototype | Medium | +| ๐ŸŸก P3 | Migrate Workers List to real app (`client/workers`) | Medium | +| ๐ŸŸก P3 | Formally document undocumented features (NFC, History tab, etc.) | Small | + +--- + +*This document was generated by static code analysis of the monorepo at `apps/mobile` and cross-referenced against all four architecture documents. No runtime behavior was observed. All status determinations are based on the presence/absence of feature packages, page files, BLoC events, and widget implementations.* diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 00000000..fd1f30e1 --- /dev/null +++ b/docs/api-contracts.md @@ -0,0 +1,266 @@ +# KROW Workforce API Contracts + +This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details. + +--- + +## Staff Application + +### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info) +#### Setup / User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, role }` | +| **Notes** | Required after OTP verification to route users. | + +#### Create Default User API +| Field | Description | +|---|---| +| **Endpoint name** | `/createUser` | +| **Purpose** | Inserts a base user record into the system during initial signup. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `role: UserBaseRole` | +| **Outputs** | `id` of newly created User | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. | + +#### Get Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getStaffByUserId` | +| **Purpose** | Finds the specific Staff record associated with the base user ID. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | +| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. | + +#### Update Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaff` | +| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. | +| **Outputs** | `id` | +| **Notes** | Called incrementally during profile setup wizard. | + +### Home Page (worker_home_page.dart) & Benefits Overview +#### Load Today/Tomorrow Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/getApplicationsByStaffId` | +| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | +| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. | + +#### List Recommended Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/listShifts` | +| **Purpose** | Fetches open shifts that are available for the staff to apply to. | +| **Operation** | Query | +| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. | +| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. | + +#### Benefits Summary API +| Field | Description | +|---|---| +| **Endpoint name** | `/listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | +| **Notes** | Calculates `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages (shifts_page.dart) +#### List Available Shifts Filtered +| Field | Description | +|---|---| +| **Endpoint name** | `/filterShifts` | +| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | +| **Operation** | Query | +| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | +| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | +| **Notes** | - | + +#### Get Shift Details +| Field | Description | +|---|---| +| **Endpoint name** | `/getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. | +| **Operation** | Query | +| **Inputs** | `id: UUID!` | +| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | +| **Notes** | - | + +#### Apply To Shift +| Field | Description | +|---|---| +| **Endpoint name** | `/createApplication` | +| **Purpose** | Worker submits an intent to take an open shift. | +| **Operation** | Mutation | +| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` | +| **Outputs** | `Application ID` | +| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. | + +### Availability Page (availability_page.dart) +#### Get Default Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` | +| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | +| **Notes** | - | + +#### Update Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) | +| **Purpose** | Upserts availability preferences. | +| **Operation** | Mutation | +| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | +| **Outputs** | `id` | +| **Notes** | Called individually per day edited. | + +### Payments Page (payments_page.dart) +#### Get Recent Payments +| Field | Description | +|---|---| +| **Endpoint name** | `/listRecentPaymentsByStaffId` | +| **Purpose** | Loads the history of earnings and timesheets completed by the staff. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `Payments { amount, processDate, shiftId, status }` | +| **Notes** | Displays historical metrics under Earnings tab. | + +### Compliance / Profiles (Agreements, W4, I9, Documents) +#### Get Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/getTaxFormsByStaffId` | +| **Purpose** | Check the filing status of I9 and W4 forms. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | +| **Notes** | Required for staff to be eligible for shifts. | + +#### Update Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type. | +| **Operation** | Mutation | +| **Inputs** | `id`, `dataPoints...` | +| **Outputs** | `id` | +| **Notes** | Updates compliance state. | + +--- + +## Client Application + +### Authentication / Intro (Sign In, Get Started) +#### Client User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, userRole }` | +| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. | + +#### Get Business Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getBusinessByUserId` | +| **Purpose** | Maps the authenticated user to their client business context. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Business { id, businessName, email, contactName }` | +| **Notes** | Used to set the working scopes (Business ID) across the entire app. | + +### Hubs Page (client_hubs_page.dart, edit_hub.dart) +#### List Hubs +| Field | Description | +|---|---| +| **Endpoint name** | `/listTeamHubsByBusinessId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` | +| **Notes** | - | + +#### Update / Delete Hub +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` | +| **Purpose** | Edits or archives a Hub location. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) | +| **Outputs** | `id` | +| **Notes** | - | + +### Orders Page (create_order, view_orders) +#### Create Order +| Field | Description | +|---|---| +| **Endpoint name** | `/createOrder` | +| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). | +| **Operation** | Mutation | +| **Inputs** | `businessId`, `eventName`, `orderType`, `status` | +| **Outputs** | `id` (Order ID) | +| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. | + +#### List Orders +| Field | Description | +|---|---| +| **Endpoint name** | `/getOrdersByBusinessId` | +| **Purpose** | Retrieves all ongoing and past staff requests from the client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Orders { id, eventName, shiftCount, status }` | +| **Notes** | - | + +### Billing Pages (billing_page.dart, pending_invoices) +#### List Invoices +| Field | Description | +|---|---| +| **Endpoint name** | `/listInvoicesByBusinessId` | +| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Invoices { id, amountDue, issueDate, status }` | +| **Notes** | Used across all Billing view tabs. | + +#### Mark Invoice +| Field | Description | +|---|---| +| **Endpoint name** | `/updateInvoice` | +| **Purpose** | Marks an invoice as disputed or pays it (changes status). | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `status: InvoiceStatus` | +| **Outputs** | `id` | +| **Notes** | Disputing usually involves setting a memo or flag. | + +### Reports Page (reports_page.dart) +#### Get Coverage Stats +| Field | Description | +|---|---| +| **Endpoint name** | `/getCoverageStatsByBusiness` | +| **Purpose** | Provides data on fulfillments rates vs actual requests. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` | +| **Notes** | Driven mostly by aggregated backend views. | + +--- + +*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.* diff --git a/docs/available_gql.txt b/docs/available_gql.txt new file mode 100644 index 00000000..54380559 Binary files /dev/null and b/docs/available_gql.txt differ diff --git a/docs/research/flutter-testing-tools.md b/docs/research/flutter-testing-tools.md new file mode 100644 index 00000000..d7cde701 --- /dev/null +++ b/docs/research/flutter-testing-tools.md @@ -0,0 +1,87 @@ +# ๐Ÿ“ฑ Research: Flutter Integration Testing Evaluation +**Issue:** #533 +**Focus:** Maestro vs. Marionette MCP (LeanCode) +**Status:** โœ… Completed +**Target Apps:** `KROW Client App` & `KROW Staff App` + +--- + +## 1. Executive Summary & Recommendation + +Following a technical spike implementing full authentication flows (Login/Signup) for both KROW platforms, **Maestro is the recommended integration testing framework.** + +While **Marionette MCP** offers an innovative LLM-driven approach for exploratory debugging, it lacks the determinism required for a production-grade CI/CD pipeline. Maestro provides the stability, speed, and native OS interaction necessary to gate our releases effectively. + +### Why Maestro Wins for KROW: +* **Zero-Flake Execution:** Built-in wait logic handles Firebase Auth latency without hard-coded `sleep()` calls. +* **Platform Parity:** Single `.yaml` definitions drive both iOS and Android build variants. +* **Non-Invasive:** Maestro tests the compiled `.apk` or `.app` (Black-box), ensuring we test exactly what the user sees. +* **System Level Access:** Handles native OS permission dialogs (Camera/Location/Notifications) which Marionette cannot "see." + +--- + +## 2. Technical Evaluation Matrix + +| Criteria | Maestro | Marionette MCP | Winner | +| :--- | :--- | :--- | :--- | +| **Test Authoring** | **High Speed:** Declarative YAML; Maestro Studio recorder. | **Variable:** Requires precise Prompt Engineering. | **Maestro** | +| **Execution Latency** | **Low:** Instantaneous interaction (~5s flows). | **High:** LLM API roundtrips (~45s+ flows). | **Maestro** | +| **Environment** | Works on Release/Production builds. | Restricted to Debug/Profile modes. | **Maestro** | +| **CI/CD Readiness** | Native CLI; easy GitHub Actions integration. | High overhead; depends on external AI APIs. | **Maestro** | +| **Context Awareness** | Interacts with Native OS & Bottom Sheets. | Limited to the Flutter Widget Tree. | **Maestro** | + +--- + +## 3. Spike Analysis & Findings + +### Tool A: Maestro (The Standard) +We verified the `login.yaml` and `signup.yaml` flows across both apps. Maestro successfully abstracted the asynchronous nature of our **Data Connect** and **Firebase** backends. + +* **Pros:** * **Semantics Driven:** By targeting `Semantics(identifier: '...')` in our `/design_system/`, tests remain stable even if the UI text changes for localization. + * **Automatic Tolerance:** It detects spinning loaders and waits for destination widgets automatically. +* **Cons:** * Requires strict adherence to adding `Semantics` wrappers on all interactive components. + +### Tool B: Marionette MCP (The Experiment) +We spiked this using the `marionette_flutter` binding and executing via **Cursor/Claude**. + +* **Pros:** * Phenomenal for visual "smoke testing" and live-debugging UI issues via natural language. +* **Cons:** * **Non-Deterministic:** Prone to "hallucinations" during heavy network traffic. + * **Architecture Blocker:** Requires the Dart VM Service to be active, making it impossible to test against hardened production builds. + +--- + +## 4. Implementation & Migration Blueprint + + + +### Phase 1: Semantics Enforcement +We must enforce a linting rule or PR checklist: All interactive widgets in `@krow/design_system` must include a unique `identifier`. + +```dart +// Standardized Implementation +Semantics( + identifier: 'login_submit_button', + child: KrowPrimaryButton( + onPressed: _handleLogin, + label: 'Sign In', + ), +) +``` + +### Phase 2: Repository Structure (Implemented) +Maestro flows are co-located with each app: + +* `apps/mobile/apps/client/maestro/login.yaml` โ€” Client login +* `apps/mobile/apps/client/maestro/signup.yaml` โ€” Client signup +* `apps/mobile/apps/staff/maestro/login.yaml` โ€” Staff login (phone + OTP) +* `apps/mobile/apps/staff/maestro/signup.yaml` โ€” Staff signup (phone + OTP) + +Each directory has a README with run instructions. + +**Marionette MCP:** `marionette_flutter` is added to both apps; `MarionetteBinding` is initialized in debug mode. See [marionette-spike-usage.md](marionette-spike-usage.md) for prompts and workflow. + +### Phase 3: CI/CD Integration +The Maestro CLI will be added to our **GitHub Actions** workflow to automate quality gates. + +* **Trigger:** Every PR targeting `main` or `develop`. +* **Action:** Generate a build, execute `maestro test`, and block merge on failure. diff --git a/docs/research/maestro-test-run-instructions.md b/docs/research/maestro-test-run-instructions.md new file mode 100644 index 00000000..a4fb80e7 --- /dev/null +++ b/docs/research/maestro-test-run-instructions.md @@ -0,0 +1,84 @@ +# How to Run Maestro Integration Tests + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +--- + +## Step-by-step: Run login tests + +### 1. Install Maestro CLI + +```bash +curl -Ls "https://get.maestro.mobile.dev" | bash +``` + +Or: https://maestro.dev/docs/getting-started/installation + +### 2. Add Firebase test phone (Staff app only) + +In [Firebase Console](https://console.firebase.google.com) โ†’ your project โ†’ **Authentication** โ†’ **Sign-in method** โ†’ **Phone** โ†’ **Phone numbers for testing**: + +- Add: **+1 5557654321** with verification code **123456** + +### 3. Build and install the apps + +From the **project root**: + +```bash +# Client +make mobile-client-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/client/build/app/outputs/flutter-apk/app-debug.apk + +# Staff +make mobile-staff-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/staff/build/app/outputs/flutter-apk/app-debug.apk +``` + +Or run the app on a connected device/emulator: `make mobile-client-dev-android DEVICE=` (then Maestro can launch the already-installed app by appId). + +### 4. Run Maestro tests + +From the **project root** (`e:\Krow-google\krow-workforce`): + +```bash +# Client login (uses legendary@krowd.com / Demo2026!) +maestro test apps/mobile/apps/client/maestro/login.yaml + +# Staff login (uses 5557654321 / OTP 123456) +maestro test apps/mobile/apps/staff/maestro/login.yaml +``` + +### 5. Run signup tests (optional) + +**Client signup** โ€” set env vars first: +```bash +$env:MAESTRO_CLIENT_EMAIL="newuser@example.com" +$env:MAESTRO_CLIENT_PASSWORD="YourPassword123!" +$env:MAESTRO_CLIENT_COMPANY="Test Company" +maestro test apps/mobile/apps/client/maestro/signup.yaml +``` + +**Staff signup** โ€” use a new Firebase test phone: +```bash +# Add +1 555-555-0000 / 123456 in Firebase, then: +$env:MAESTRO_STAFF_SIGNUP_PHONE="5555550000" +maestro test apps/mobile/apps/staff/maestro/signup.yaml +``` + +--- + +## Checklist + +- [ ] Maestro CLI installed +- [ ] Firebase test phone +1 5557654321 / 123456 added (for staff) +- [ ] Client app built and installed +- [ ] Staff app built and installed +- [ ] Run from project root: `maestro test apps/mobile/apps/client/maestro/login.yaml` +- [ ] Run from project root: `maestro test apps/mobile/apps/staff/maestro/login.yaml` diff --git a/docs/research/marionette-spike-usage.md b/docs/research/marionette-spike-usage.md new file mode 100644 index 00000000..09553e89 --- /dev/null +++ b/docs/research/marionette-spike-usage.md @@ -0,0 +1,58 @@ +# Marionette MCP Spike โ€” Usage Guide + +**Issue:** #533 +**Purpose:** Document how to run the Marionette MCP spike for auth flows. + +## Prerequisites + +1. **Marionette MCP server** โ€” Install globally: + ```bash + dart pub global activate marionette_mcp + ``` + +2. **Add Marionette to Cursor** โ€” In `.cursor/mcp.json` or global config: + ```json + { + "mcpServers": { + "marionette": { + "command": "marionette_mcp", + "args": [] + } + } + } + ``` + +3. **Run app in debug mode** โ€” The app must be running with VM Service: + ```bash + cd apps/mobile && flutter run -d + ``` + +4. **Get VM Service URI** โ€” From the `flutter run` output, copy the `ws://127.0.0.1:XXXX/ws` URI (often shown in the DevTools link). + +## Spike flows (AI agent prompts) + +Use these prompts with the Marionette MCP connected to the running app. + +### Client โ€” Login + +> Connect to the app using the VM Service URI. Navigate to the Get Started screen, tap "Sign In", enter legendary@krowd.com and Demo2026!, then tap "Sign In". Verify we land on the home screen. + +### Client โ€” Sign up + +> Connect to the app. Tap "Create Account", fill in Company, Email, Password (and confirm) with new credentials, then tap "Create Account". Verify we land on the home screen. + +### Staff โ€” Login + +> Connect to the app. Tap "Log In", enter phone number 5557654321, tap "Send Code", enter OTP 123456, tap "Continue". Verify we reach the staff home screen. +> (Firebase test phone: +1 555-765-4321 / OTP 123456) + +### Staff โ€” Sign up + +> Connect to the app. Tap "Sign Up", enter a NEW phone number (Firebase test phone), tap "Send Code", enter OTP, tap "Continue". Verify we reach Profile Setup or staff home. + +## Limitations observed (from spike) + +- **Debug only** โ€” Marionette needs the Dart VM Service; does not work with release builds. +- **Non-deterministic** โ€” LLM-driven actions can vary in behavior and timing. +- **Latency** โ€” Each step involves API roundtrips (~45s+ for full flow vs ~5s for Maestro). +- **Best use** โ€” Exploratory testing, live debugging, smoke checks during development. diff --git a/internal/launchpad/assets/documents/data connect/backend_manual.md b/internal/launchpad/assets/documents/data connect/backend_manual.md new file mode 100644 index 00000000..a256a882 --- /dev/null +++ b/internal/launchpad/assets/documents/data connect/backend_manual.md @@ -0,0 +1,294 @@ +# Krow Workforce โ€“ Backend Manual +Firebase Data Connect + Cloud SQL (PostgreSQL) + +--- + +## 1. Backend Overview + +This project uses Firebase Data Connect with Cloud SQL (PostgreSQL) as the main backend system. + +The architecture is based on: + +- GraphQL Schemas โ†’ Define database tables +- Connectors (Queries & Mutations) โ†’ Data access layer +- Cloud SQL โ†’ Real database +- Auto-generated SDK โ†’ Used by Web & Mobile apps +- Makefile โ†’ Automates backend workflows + +The goal is to keep the backend scalable, structured, and aligned with Web and Mobile applications. + +--- + +## 2. Project Structure + +``` +dataconnect/ +โ”‚ +โ”œโ”€โ”€ dataconnect.yaml +โ”œโ”€โ”€ schema/ +โ”‚ โ”œโ”€โ”€ Staff.gql +โ”‚ โ”œโ”€โ”€ Vendor.gql +โ”‚ โ”œโ”€โ”€ Business.gql +โ”‚ โ””โ”€โ”€ ... +โ”‚ +โ”œโ”€โ”€ connector/ +โ”‚ โ”œโ”€โ”€ staff/ +โ”‚ โ”‚ โ”œโ”€โ”€ queries.gql +โ”‚ โ”‚ โ””โ”€โ”€ mutations.gql +โ”‚ โ”œโ”€โ”€ invoice/ +โ”‚ โ””โ”€โ”€ ... +โ”‚ +โ”œโ”€โ”€ connector/connector.yaml +โ”‚ +docs/backend-diagrams/ +โ”‚ โ”œโ”€โ”€ business_uml_diagram.mmd +โ”‚ โ”œโ”€โ”€ staff_uml_diagram.mmd +โ”‚ โ”œโ”€โ”€ team_uml_diagram.mmd +โ”‚ โ”œโ”€โ”€ user_uml_diagram.mmd +โ”‚ โ””โ”€โ”€ vendor_uml_diagram_simplify.mmd +``` + +--- + +## 3. dataconnect.yaml (Main Configuration) + +```yaml +specVersion: "v1" +serviceId: "krow-workforce-db" +location: "us-central1" + +schema: + source: "./schema" + datasource: + postgresql: + database: "krow_db" + cloudSql: + instanceId: "krow-sql" + +connectorDirs: ["./connector"] +``` + +### Purpose + +| Field | Description | +|------|------------| +| serviceId | Data Connect service name | +| schema.source | Where GraphQL schemas live | +| datasource | Cloud SQL connection | +| connectorDirs | Where queries/mutations are | + +--- + +## 4. Database Schemas + +All database schemas are located in: + +``` +dataconnect/schema/ +``` + +Each `.gql` file represents a table: + +- Staff.gql +- Invoice.gql +- ShiftRole.gql +- Application.gql +- etc. + +Schemas define: + +- Fields +- Enums +- Relationships (`@ref`) +- Composite keys (`key: []`) + +--- + +## 5. Queries & Mutations (Connectors) + +Located in: + +``` +dataconnect/connector// +``` + +Example: + +``` +dataconnect/connector/staff/queries.gql +dataconnect/connector/staff/mutations.gql +``` + +Each folder represents one entity. + +This layer defines: + +- listStaff +- getStaffById +- createStaff +- updateStaff +- deleteStaff +- etc. + +--- + +## 6. connector.yaml (SDK Generator) + +```yaml +connectorId: example +generate: + dartSdk: + - outputDir: ../../mobile/staff/staff_app_mvp/lib/dataconnect_generated + package: dataconnect_generated/generated.dart + - outputDir: ../../mobile/client/client_app_mvp/lib/dataconnect_generated + package: dataconnect_generated/generated.dart +``` + +This file generates the SDK for: + +- Staff Mobile App +- Client Mobile App + +--- + +## 7. What is the SDK? + +The SDK is generated using: + +```bash +firebase dataconnect:sdk:generate +``` + +It allows the apps to: + +- Call queries/mutations +- Use strong typing +- Avoid manual GraphQL +- Reduce runtime errors + +Example in Flutter: + +```dart +client.listStaff(); +client.createInvoice(); +``` + +--- + +## 8. Makefile โ€“ Automation Commands + +### Main Commands + +| Command | Purpose | +|--------|---------| +| dataconnect-enable-apis | Enable required APIs | +| dataconnect-init | Initialize Data Connect | +| dataconnect-deploy | Deploy schemas | +| dataconnect-sql-migrate | Apply DB migrations | +| dataconnect-generate-sdk | Generate SDK | +| dataconnect-sync | Full backend update | +| dataconnect-test | Test without breaking | +| dataconnect-seed | Insert seed data | +| dataconnect-bootstrap-db | Create Cloud SQL | + +--- + +## 9. Correct Backend Workflow + +### Production Flow + +```bash +make dataconnect-sync +``` + +Steps: + +1. Deploy schema +2. Run SQL migrations +3. Generate SDK + +--- + +### Safe Test Flow + +```bash +make dataconnect-test +``` + +This runs: + +- Deploy dry-run +- SQL diff +- Shows errors without changing DB + +--- + +## 10. Seed Data + +Current command: + +```make +dataconnect-seed: + @firebase dataconnect:execute seeds/seed_min.graphql --project=$(FIREBASE_ALIAS) +``` + +Purpose: + +- Validate schema +- Detect missing tables +- Prevent bad inserts + +--- + +## 11. UML Diagrams + +Located in: + +``` +docs/backend-diagrams/ +``` + +Divided by role: + +| File | Scope | +|------|-------| +| user_uml_diagram.mmd | User | +| staff_uml_diagram.mmd | Staff | +| vendor_uml_diagram_simplify.mmd | Vendor | +| business_uml_diagram.mmd | Business | +| team_uml_diagram.mmd | Teams | + +Used with Mermaid to visualize relationships. + +--- + +## 12. Core Business Workflow + +```text +Order + โ†’ Shift + โ†’ ShiftRole + โ†’ Application + โ†’ Workforce + โ†’ Assignment + โ†’ Invoice + โ†’ RecentPayment +``` + +This represents the full work & payment lifecycle. + +--- + +## 13. Final Notes + +This backend is designed to: + +- Scale efficiently +- Maintain data consistency +- Align Web & Mobile models +- Support reporting and billing +- Avoid duplicated data + +--- + +END OF MANUAL diff --git a/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md b/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md new file mode 100644 index 00000000..7585a341 --- /dev/null +++ b/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md @@ -0,0 +1,1300 @@ +# Data Connect Architecture Guide V.3 + +## 1. Introduction +This guide consolidates the Data Connect domain documentation into a single reference for engineers, product stakeholders, and QA. Use it to understand the entity relationships, operational flows, and available API operations across the platform. + +## 2. Table of Contents +- [System Overview](#system-overview) +- [Identity Domain](#identity-domain) +- [Operations Domain](#operations-domain) +- [Billing Domain](#billing-domain) +- [Teams Domain](#teams-domain) +- [Messaging Domain](#messaging-domain) +- [Compliance Domain](#compliance-domain) +- [Learning Domain](#learning-domain) +- [Sequence Diagrams](#sequence-diagrams) +- [API Catalog](#api-catalog) + +## System Overview + +### Summary +Summarizes the high-level relationships between core entities in the system. +Highlights the user role model and how orders progress into staffing, assignments, and invoices. +Includes the communication entities that connect users, conversations, and messages. + +### Full Content +# System Overview Flowchart + +## Description +This flowchart illustrates the high-level relationships between the main entities in the system. It shows the core workflows for user roles, order processing, and communication. + +## Flowchart +```mermaid +flowchart LR + subgraph "User Roles" + U(User) --> S(Staff) + U --> V(Vendor) + U --> B(Business) + end + + subgraph "Order & Fulfillment" + B --> O(Order) + V --> O(Order) + O --> SH(Shift) + SH --> APP(Application) + S --> APP + APP --> AS(Assignment) + AS --> I(Invoice) + end + + subgraph "Communication" + C(Conversation) --> M(Message) + UC(UserConversation) --> U + UC --> C + end + + style S fill:#f9f,stroke:#333,stroke-width:2px + style V fill:#ccf,stroke:#333,stroke-width:2px + style B fill:#cfc,stroke:#333,stroke-width:2px +``` + +## Identity Domain + +### Summary +Explains how users map to staff, vendors, and businesses in the identity model. +Shows the role hierarchy from staff roles to roles and role categories. +Focuses on the core identity relationships used across the platform. + +### Full Content +# Identity Domain Flowchart + +## Description +Este diagrama de flujo detalla las relaciones de alto nivel entre las entidades de identidad clave del sistema, como usuarios, personal, proveedores, empresas y sus roles asociados. + +## Flowchart +```mermaid +flowchart LR + U(User) --> S(Staff) + U --> V(Vendor) + U --> B(Business) + + S --> SR(StaffRole) + SR --> R(Role) + R --> RC(RoleCategory) + + style U fill:#f9f,stroke:#333,stroke-width:2px + style S fill:#ccf,stroke:#333,stroke-width:2px + style V fill:#cfc,stroke:#333,stroke-width:2px + style B fill:#ffc,stroke:#333,stroke-width:2px +``` + +## Operations Domain + +### Summary +Describes the operational lifecycle from orders through shifts and applications. +Connects staffing and workforce records to assignments and invoicing outcomes. +Illustrates the end-to-end flow for fulfillment and billing readiness. + +### Full Content +# Operations Domain Flowchart + +## Description +This flowchart explains the lifecycle of an order, from its creation and staffing to the final invoice generation. + +## Flowchart +```mermaid +flowchart TD + O(Order) --> S(Shift) + S --> SR(ShiftRole) + SR --> A(Application) + U(User) --> A + A --> W(WorkForce) + W --> AS(Assignment) + AS --> I(Invoice) + + style O fill:#f9f,stroke:#333,stroke-width:2px + style S fill:#ccf,stroke:#333,stroke-width:2px + style A fill:#cfc,stroke:#333,stroke-width:2px + style AS fill:#ffc,stroke:#333,stroke-width:2px + style I fill:#f99,stroke:#333,stroke-width:2px +``` + +## Billing Domain + +### Summary +Centers the billing process on invoices linked to orders, businesses, and vendors. +Shows how recent payments attach to invoices and reference applications for context. +Provides the upstream operational context that feeds billing records. + +### Full Content +# Billing Domain Flowchart + +## Description +Based on the repository's schema, the billing process centers around the `Invoice` entity. An `Invoice` is generated in the context of an `Order` and is explicitly linked to both a `Business` (the client) and a `Vendor` (the provider). Each invoice captures essential details from these parent entities. Financial transactions are tracked through the `RecentPayment` entity, which is directly tied to a specific `Invoice`, creating a clear record of payments made against that invoice. + +## Verified Relationships (evidence) +- `Invoice.vendorId` -> `Vendor.id` (source: `dataconnect/schema/invoice.gql`) +- `Invoice.businessId` -> `Business.id` (source: `dataconnect/schema/invoice.gql`) +- `Invoice.orderId` -> `Order.id` (source: `dataconnect/schema/invoice.gql`) +- `RecentPayment.invoiceId` -> `Invoice.id` (source: `dataconnect/schema/recentPayment.gql`) +- `RecentPayment.applicationId` -> `Application.id` (source: `dataconnect/schema/recentPayment.gql`) + +## Flowchart +```mermaid +flowchart TD + %% ----------------------------- + %% Billing Core + %% ----------------------------- + B(Business) --> O(Order) + V(Vendor) --> O + O --> I(Invoice) + I --> RP(RecentPayment) + A(Application) --> RP + + %% ----------------------------- + %% Upstream Operations (Context) + %% ----------------------------- + subgraph OPS[Upstream Operations Context] + O --> S(Shift) + S --> SR(ShiftRole) + SR --> A + ST(Staff) --> A + A --> AS(Assignment) + W(Workforce) --> AS + ST --> W + end +``` + +## Teams Domain + +### Summary +Details how teams, members, hubs, and departments structure organizational data. +Covers task management via tasks, member assignments, and task comments. +Notes verified and missing relationships that affect traceability in the schema. + +### Full Content +# Teams Domain Flowchart + +## Description +The Teams domain in this repository organizes users and their associated tasks. The `Team` is the central entity, with a `User` joining via the `TeamMember` join table, which defines their role. Teams can be structured using `TeamHub` (locations) and `TeamHudDepartment` (departments). The domain also includes task management. A `Task` can be assigned to a `TeamMember` through the `MemberTask` entity. Communication on tasks is handled by `TaskComment`, which is linked directly to the `TeamMember` who made the comment, providing a clear link between team structure and actionable work. + +## Entities in Scope +- Team +- TeamMember +- User +- TeamHub +- TeamHudDepartment +- Task +- MemberTask +- TaskComment + +## Verified Relationships (evidence) +- `TeamMember.teamId` -> `Team.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamMember.userId` -> `User.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamMember.teamHubId` -> `TeamHub.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamHub.teamId` -> `Team.id` (source: `dataconnect/schema/teamHub.gql`, implicit via field name) +- `TeamHudDepartment.teamHubId` -> `TeamHub.id` (source: `dataconnect/schema/teamHudDeparment.gql`) +- `MemberTask.teamMemberId` -> `TeamMember.id` (source: `dataconnect/schema/memberTask.gql`) +- `MemberTask.taskId` -> `Task.id` (source: `dataconnect/schema/memberTask.gql`) +- `TaskComment.teamMemberId` -> `TeamMember.id` (source: `dataconnect/schema/task_comment.gql`) +- Not found: `Team.ownerId` is a generic `String` and does not have a `@ref` to `Vendor` or `Business`. +- Not found: `TaskComment.taskId` exists but has no `@ref` to `Task.id`. + +## Flowchart +```mermaid +--- +config: + layout: elk +--- +--- +config: + layout: elk +--- +flowchart TB + subgraph STRUCTURE[Team Structure] + T(Team) --> TM(TeamMember) + T --> TH(TeamHub) + TH --> THD(TeamHudDepartment) + U(User) --> TM + TM --> TH + end + + subgraph WORK[Work & Tasks] + TK(Task) --> MT(MemberTask) + TM --> MT + TM --> TC(TaskComment) + TK --> TC + end + +``` + +## Messaging Domain + +### Summary +Defines conversations as the container for chat metadata and history. +Links messages and user participation through user conversations. +Distinguishes verified and inferred relationships between entities. + +### Full Content +# Messaging Domain Flowchart + +## Description +The messaging system is designed around three core entities. The `Conversation` entity acts as the central container, holding metadata about a specific chat, such as its subject and type (e.g., group chat, client-vendor). The actual content of the conversation is stored in the `Message` entity, where each message is linked to its parent `Conversation` and the `User` who sent it. To track the state for each participant, the `UserConversation` entity links a `User` to a `Conversation` and stores per-user data, such as the number of unread messages and when they last read the chat. + +## Entities in Scope +- Conversation +- Message +- UserConversation +- User + +## Verified Relationships (evidence) +- `Message.senderId` -> `User.id` (source: `dataconnect/schema/message.gql`) +- `UserConversation.conversationId` -> `Conversation.id` (source: `dataconnect/schema/userConversation.gql`) +- `UserConversation.userId` -> `User.id` (source: `dataconnect/schema/userConversation.gql`) + +## Inferred Relationships (if any) +- `Message.conversationId` -> `Conversation.id` (source: `dataconnect/schema/message.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph "Conversation Metadata" + C(Conversation) + end + + subgraph "Message Content & User State" + M(Message) + UC(UserConversation) + U(User) + end + + C -- Inferred --- M + C -- Verified --- UC + + U -- Verified --- UC + U -- Verified --- M +``` + +## Compliance Domain + +### Summary +Explains how staff compliance is tracked through documents and submissions. +Includes required documents, tax forms, and certificates tied to staff records. +Separates verified links from inferred relationships for compliance entities. + +### Full Content +# Compliance Domain Flowchart + +## Description +The compliance domain manages the necessary documentation and certifications for staff members. The system defines a list of document types via the `Document` entity. Staff members submit their compliance files through `StaffDocument`, which links a specific staff member to a generic document definition. Additionally, `RequiredDoc`, `TaxForm`, and `Certificate` entities are used to track other specific compliance items, such as mandatory documents, tax forms (like W-4s), and professional certificates, all of which are linked back to a particular staff member. + +## Entities in Scope +- Document +- StaffDocument +- RequiredDoc +- TaxForm +- Certificate +- Staff + +## Verified Relationships (evidence) +- `StaffDocument.documentId` -> `Document.id` (source: `dataconnect/schema/staffDocument.gql`) +- `Certificate.staffId` -> `Staff.id` (source: `dataconnect/schema/certificate.gql`) + +## Inferred Relationships (if any) +- `StaffDocument.staffId` -> `Staff.id` (source: `dataconnect/schema/staffDocument.gql`, inferred from field name) +- `RequiredDoc.staffId` -> `Staff.id` (source: `dataconnect/schema/requiredDoc.gql`, inferred from field name) +- `TaxForm.staffId` -> `Staff.id` (source: `dataconnect/schema/taxForm.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph subGraph0["Compliance Requirements"] + D("Document") + end + subgraph subGraph1["Staff Submissions & Documents"] + S("Staff") + SD("StaffDocument") + TF("TaxForm") + C("Certificate") + end + D -- Verified --> SD + S -- Inferred --> SD & TF + S -- Verified --> C +``` + +## Learning Domain + +### Summary +Outlines the training model with courses, categories, and levels. +Shows how staff progress is captured via staff course records. +Calls out relationships that are inferred versus explicitly modeled. + +### Full Content +# Learning Domain Flowchart + +## Description +The learning domain provides a structured training system for staff. The core component is the `Course`, which represents an individual training module with a title, description, and associated `Category`. While the `Level` entity exists to define progression tiers (e.g., based on experience points), it is not directly linked to courses in the current schema. The `StaffCourse` entity tracks the progress of a staff member in a specific course, recording their completion status and timestamps. Certificates are not explicitly linked to course completion in the schema. + +## Entities in Scope +- Course +- Category +- Level +- StaffCourse +- Staff + +## Verified Relationships (evidence) +- `Course.categoryId` -> `Category.id` (source: `dataconnect/schema/course.gql`) + +## Inferred Relationships (if any) +- `StaffCourse.staffId` -> `Staff.id` (source: `dataconnect/schema/staffCourse.gql`, inferred from field name) +- `StaffCourse.courseId` -> `Course.id` (source: `dataconnect/schema/staffCourse.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph "Training Structure" + C(Course) + CAT(Category) + L(Level) + end + + subgraph "Staff Participation" + S(Staff) + SC(StaffCourse) + end + + CAT -- Verified --> C + + S -- Verified --> SC + C -- Verified --> SC +``` + +## Sequence Diagrams + +### Summary +Walks through the order-to-invoice sequence based on connector operations. +Lists the verified mutation steps that drive the operational flow. +Visualizes participant interactions from creation through billing. + +### Full Content +# Operations Sequence Diagrams + +## Flow 1: Order to Invoice + +### Description +Based on the repository's connector operations, the operational flow begins when a user creates an `Order`. From this order, one or more `Shifts` are generated. A `Staff` member can then apply to a specific `Shift`, creating an `Application`. Subsequently, an `Assignment` is created, linking a `Workforce` member to that `Shift`. While this represents the staffing and fulfillment part of the process, the billing cycle is handled separately. An `Invoice` is generated directly from the parent `Order`, rather than from the individual assignments, consolidating all billing at the order level. + +### Verified Steps (Evidence) +- `createOrder` (source: `dataconnect/connector/order/mutations.gql`) +- `createShift` (source: `dataconnect/connector/shift/mutations.gql`) +- `createShiftRole` (source: `dataconnect/connector/shiftRole/mutations.gql`) +- `createApplication` (source: `dataconnect/connector/application/mutations.gql`) +- `CreateAssignment` (source: `dataconnect/connector/assignment/mutations.gql`) +- `createInvoice` (source: `dataconnect/connector/invoice/mutations.gql`) + +### Sequence Diagram +```mermaid +sequenceDiagram + participant Business as Business (Client) + participant Vendor as Vendor (Provider) + participant Order + participant Shift + participant ShiftRole + participant Staff + participant Application + participant Workforce + participant Assignment + participant Invoice + + Business->>Order: createOrder(businessId, vendorId, ...) + Order-->>Business: Order created (orderId) + + Vendor->>Shift: createShift(orderId, ...) + Shift-->>Vendor: Shift created (shiftId) + + Vendor->>ShiftRole: createShiftRole(shiftId, roleId, workersNeeded, rate, ...) + ShiftRole-->>Vendor: ShiftRole created (shiftRoleId) + + Staff->>Application: createApplication(shiftId OR shiftRoleId, staffId, ...) + Application-->>Staff: Application submitted (applicationId) + + Vendor->>Workforce: createWorkforce(applicationId / staffId / shiftId, ...) + Workforce-->>Vendor: Workforce created (workforceId) + + Vendor->>Assignment: createAssignment(shiftId, workforceId, staffId, ...) + Assignment-->>Vendor: Assignment created (assignmentId) + + Vendor->>Invoice: createInvoice(orderId, businessId, vendorId, ...) + Invoice-->>Vendor: Invoice created (invoiceId) + +``` + +## API Catalog + +### Summary +Lists every GraphQL query and mutation in the Data Connect connectors. +Provides parameters and top-level return/affect fields for each operation. +Organizes operations by entity folder for quick discovery and reference. + +### Full Content +# API Catalog โ€“ Data Connect + +## Overview +This catalog enumerates every GraphQL query and mutation defined in the Data Connect connector folders under `prototypes/dataconnect/connector/`. Use it to discover available operations, required parameters, and the top-level fields returned or affected by each operation. + +## account + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAccounts` | List accounts | โ€” | `accounts` | +| `getAccountById` | Get account by id | `$id: UUID!` | `account` | +| `getAccountsByOwnerId` | Get accounts by owner id | `$ownerId: UUID!` | `accounts` | +| `filterAccounts` | Filter accounts | `$bank: String`
`$type: AccountType`
`$isPrimary: Boolean`
`$ownerId: UUID` | `accounts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createAccount` | Create account | `$bank: String!`
`$type: AccountType!`
`$last4: String!`
`$isPrimary: Boolean`
`$ownerId: UUID!`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_insert` | +| `updateAccount` | Update account | `$id: UUID!`
`$bank: String`
`$type: AccountType`
`$last4: String`
`$isPrimary: Boolean`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_update` | +| `deleteAccount` | Delete account | `$id: UUID!` | `account_delete` | + +## activityLog + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listActivityLogs` | List activity logs | `$offset: Int`
`$limit: Int` | `activityLogs` | +| `getActivityLogById` | Get activity log by id | `$id: UUID!` | `activityLog` | +| `listActivityLogsByUserId` | List activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `listUnreadActivityLogsByUserId` | List unread activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `filterActivityLogs` | Filter activity logs | `$userId: String`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$isRead: Boolean`
`$activityType: ActivityType`
`$iconType: ActivityIconType`
`$offset: Int`
`$limit: Int` | `activityLogs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createActivityLog` | Create activity log | `$userId: String!`
`$date: Timestamp!`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String!`
`$description: String!`
`$isRead: Boolean`
`$activityType: ActivityType!` | `activityLog_insert` | +| `updateActivityLog` | Update activity log | `$id: UUID!`
`$userId: String`
`$date: Timestamp`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String`
`$description: String`
`$isRead: Boolean`
`$activityType: ActivityType` | `activityLog_update` | +| `markActivityLogAsRead` | Mark activity log as read | `$id: UUID!` | `activityLog_update` | +| `markActivityLogsAsRead` | Mark activity logs as read | `$ids: [UUID!]!` | `activityLog_updateMany` | +| `deleteActivityLog` | Delete activity log | `$id: UUID!` | `activityLog_delete` | + +## application + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listApplications` | List applications | โ€” | `applications` | +| `getApplicationById` | Get application by id | `$id: UUID!` | `application` | +| `getApplicationsByShiftId` | Get applications by shift id | `$shiftId: UUID!` | `applications` | +| `getApplicationsByShiftIdAndStatus` | Get applications by shift id and status | `$shiftId: UUID!`
`$status: ApplicationStatus!`
`$offset: Int`
`$limit: Int` | `applications` | +| `getApplicationsByStaffId` | Get applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `vaidateDayStaffApplication` | Vaidate day staff application | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `getApplicationByStaffShiftAndRole` | Get application by staff shift and role | `$staffId: UUID!`
`$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByShiftRoleKey` | List accepted applications by shift role key | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByBusinessForDay` | List accepted applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listStaffsApplicationsByBusinessForDay` | List staffs applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listCompletedApplicationsByStaffId` | List completed applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createApplication` | Create application | `$shiftId: UUID!`
`$staffId: UUID!`
`$status: ApplicationStatus!`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$origin: ApplicationOrigin!`
`$roleId: UUID!` | `application_insert` | +| `updateApplicationStatus` | Update application status | `$id: UUID!`
`$shiftId: UUID`
`$staffId: UUID`
`$status: ApplicationStatus`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$roleId: UUID` | `application_update` | +| `deleteApplication` | Delete application | `$id: UUID!` | `application_delete` | + +## assignment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAssignments` | List assignments | `$offset: Int`
`$limit: Int` | `assignments` | +| `getAssignmentById` | Get assignment by id | `$id: UUID!` | `assignment` | +| `listAssignmentsByWorkforceId` | List assignments by workforce id | `$workforceId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByWorkforceIds` | List assignments by workforce ids | `$workforceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByShiftRole` | List assignments by shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `represents` | Represents | โ€” | `assignments` | +| `filterAssignments` | Filter assignments | `$shiftIds: [UUID!]!`
`$roleIds: [UUID!]!`
`$status: AssignmentStatus`
`$offset: Int`
`$limit: Int` | `assignments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateAssignment` | Create assignment | `$workforceId: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_insert` | +| `UpdateAssignment` | Update assignment | `$id: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_update` | +| `DeleteAssignment` | Delete assignment | `$id: UUID!` | `assignment_delete` | + +## attireOption + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAttireOptions` | List attire options | โ€” | `attireOptions` | +| `getAttireOptionById` | Get attire option by id | `$id: UUID!` | `attireOption` | +| `filterAttireOptions` | Filter attire options | `$itemId: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOptions` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createAttireOption` | Create attire option | `$itemId: String!`
`$label: String!`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_insert` | +| `updateAttireOption` | Update attire option | `$id: UUID!`
`$itemId: String`
`$label: String`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_update` | +| `deleteAttireOption` | Delete attire option | `$id: UUID!` | `attireOption_delete` | + +## benefitsData + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listBenefitsData` | List benefits data | `$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `getBenefitsDataByKey` | Get benefits data by key | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData` | +| `listBenefitsDataByStaffId` | List benefits data by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanId` | List benefits data by vendor benefit plan id | `$vendorBenefitPlanId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanIds` | List benefits data by vendor benefit plan ids | `$vendorBenefitPlanIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createBenefitsData` | Create benefits data | `$vendorBenefitPlanId: UUID!`
`$staffId: UUID!`
`$current: Int!` | `benefitsData_insert` | +| `updateBenefitsData` | Update benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!`
`$current: Int` | `benefitsData_update` | +| `deleteBenefitsData` | Delete benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData_delete` | + +## business + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listBusinesses` | List businesses | โ€” | `businesses` | +| `getBusinessesByUserId` | Get businesses by user id | `$userId: String!` | `businesses` | +| `getBusinessById` | Get business by id | `$id: UUID!` | `business` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createBusiness` | Create business | `$businessName: String!`
`$contactName: String`
`$userId: String!`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup!`
`$status: BusinessStatus!`
`$notes: String` | `business_insert` | +| `updateBusiness` | Update business | `$id: UUID!`
`$businessName: String`
`$contactName: String`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup`
`$status: BusinessStatus`
`$notes: String` | `business_update` | +| `deleteBusiness` | Delete business | `$id: UUID!` | `business_delete` | + +## category + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCategories` | List categories | โ€” | `categories` | +| `getCategoryById` | Get category by id | `$id: UUID!` | `category` | +| `filterCategories` | Filter categories | `$categoryId: String`
`$label: String` | `categories` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCategory` | Create category | `$categoryId: String!`
`$label: String!`
`$icon: String` | `category_insert` | +| `updateCategory` | Update category | `$id: UUID!`
`$categoryId: String`
`$label: String`
`$icon: String` | `category_update` | +| `deleteCategory` | Delete category | `$id: UUID!` | `category_delete` | + +## certificate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCertificates` | List certificates | โ€” | `certificates` | +| `getCertificateById` | Get certificate by id | `$id: UUID!` | `certificate` | +| `listCertificatesByStaffId` | List certificates by staff id | `$staffId: UUID!` | `certificates` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateCertificate` | Create certificate | `$name: String!`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus!`
`$fileUrl: String`
`$icon: String`
`$certificationType: ComplianceType`
`$issuer: String`
`$staffId: UUID!`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_insert` | +| `UpdateCertificate` | Update certificate | `$id: UUID!`
`$name: String`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus`
`$fileUrl: String`
`$icon: String`
`$staffId: UUID`
`$certificationType: ComplianceType`
`$issuer: String`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_update` | +| `DeleteCertificate` | Delete certificate | `$id: UUID!` | `certificate_delete` | + +## clientFeedback + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listClientFeedbacks` | List client feedbacks | `$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `getClientFeedbackById` | Get client feedback by id | `$id: UUID!` | `clientFeedback` | +| `listClientFeedbacksByBusinessId` | List client feedbacks by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByVendorId` | List client feedbacks by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByBusinessAndVendor` | List client feedbacks by business and vendor | `$businessId: UUID!`
`$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `filterClientFeedbacks` | Filter client feedbacks | `$businessId: UUID`
`$vendorId: UUID`
`$ratingMin: Int`
`$ratingMax: Int`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbackRatingsByVendorId` | List client feedback ratings by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp` | `clientFeedbacks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createClientFeedback` | Create client feedback | `$businessId: UUID!`
`$vendorId: UUID!`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_insert` | +| `updateClientFeedback` | Update client feedback | `$id: UUID!`
`$businessId: UUID`
`$vendorId: UUID`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_update` | +| `deleteClientFeedback` | Delete client feedback | `$id: UUID!` | `clientFeedback_delete` | + +## conversation + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listConversations` | List conversations | `$offset: Int`
`$limit: Int` | `conversations` | +| `getConversationById` | Get conversation by id | `$id: UUID!` | `conversation` | +| `listConversationsByType` | List conversations by type | `$conversationType: ConversationType!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `listConversationsByStatus` | List conversations by status | `$status: ConversationStatus!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `filterConversations` | Filter conversations | `$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$lastMessageAfter: Timestamp`
`$lastMessageBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `conversations` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createConversation` | Create conversation | `$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_insert` | +| `updateConversation` | Update conversation | `$id: UUID!`
`$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `updateConversationLastMessage` | Update conversation last message | `$id: UUID!`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `deleteConversation` | Delete conversation | `$id: UUID!` | `conversation_delete` | + +## course + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCourses` | List courses | โ€” | `courses` | +| `getCourseById` | Get course by id | `$id: UUID!` | `course` | +| `filterCourses` | Filter courses | `$categoryId: UUID`
`$isCertification: Boolean`
`$levelRequired: String`
`$completed: Boolean` | `courses` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCourse` | Create course | `$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_insert` | +| `updateCourse` | Update course | `$id: UUID!`
`$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_update` | +| `deleteCourse` | Delete course | `$id: UUID!` | `course_delete` | + +## customRateCard + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCustomRateCards` | List custom rate cards | โ€” | `customRateCards` | +| `getCustomRateCardById` | Get custom rate card by id | `$id: UUID!` | `customRateCard` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCustomRateCard` | Create custom rate card | `$name: String!`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_insert` | +| `updateCustomRateCard` | Update custom rate card | `$id: UUID!`
`$name: String`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_update` | +| `deleteCustomRateCard` | Delete custom rate card | `$id: UUID!` | `customRateCard_delete` | + +## document + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listDocuments` | List documents | โ€” | `documents` | +| `getDocumentById` | Get document by id | `$id: UUID!` | `document` | +| `filterDocuments` | Filter documents | `$documentType: DocumentType` | `documents` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createDocument` | Create document | `$documentType: DocumentType!`
`$name: String!`
`$description: String` | `document_insert` | +| `updateDocument` | Update document | `$id: UUID!`
`$documentType: DocumentType`
`$name: String`
`$description: String` | `document_update` | +| `deleteDocument` | Delete document | `$id: UUID!` | `document_delete` | + +## emergencyContact + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listEmergencyContacts` | List emergency contacts | โ€” | `emergencyContacts` | +| `getEmergencyContactById` | Get emergency contact by id | `$id: UUID!` | `emergencyContact` | +| `getEmergencyContactsByStaffId` | Get emergency contacts by staff id | `$staffId: UUID!` | `emergencyContacts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createEmergencyContact` | Create emergency contact | `$name: String!`
`$phone: String!`
`$relationship: RelationshipType!`
`$staffId: UUID!` | `emergencyContact_insert` | +| `updateEmergencyContact` | Update emergency contact | `$id: UUID!`
`$name: String`
`$phone: String`
`$relationship: RelationshipType` | `emergencyContact_update` | +| `deleteEmergencyContact` | Delete emergency contact | `$id: UUID!` | `emergencyContact_delete` | + +## faqData + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listFaqDatas` | List faq datas | โ€” | `faqDatas` | +| `getFaqDataById` | Get faq data by id | `$id: UUID!` | `faqData` | +| `filterFaqDatas` | Filter faq datas | `$category: String` | `faqDatas` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createFaqData` | Create faq data | `$category: String!`
`$questions: [Any!]` | `faqData_insert` | +| `updateFaqData` | Update faq data | `$id: UUID!`
`$category: String`
`$questions: [Any!]` | `faqData_update` | +| `deleteFaqData` | Delete faq data | `$id: UUID!` | `faqData_delete` | + +## hub + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listHubs` | List hubs | โ€” | `hubs` | +| `getHubById` | Get hub by id | `$id: UUID!` | `hub` | +| `getHubsByOwnerId` | Get hubs by owner id | `$ownerId: UUID!` | `hubs` | +| `filterHubs` | Filter hubs | `$ownerId: UUID`
`$name: String`
`$nfcTagId: String` | `hubs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createHub` | Create hub | `$name: String!`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID!` | `hub_insert` | +| `updateHub` | Update hub | `$id: UUID!`
`$name: String`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID` | `hub_update` | +| `deleteHub` | Delete hub | `$id: UUID!` | `hub_delete` | + +## invoice + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listInvoices` | List invoices | `$offset: Int`
`$limit: Int` | `invoices` | +| `getInvoiceById` | Get invoice by id | `$id: UUID!` | `invoice` | +| `listInvoicesByVendorId` | List invoices by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByBusinessId` | List invoices by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByOrderId` | List invoices by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByStatus` | List invoices by status | `$status: InvoiceStatus!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `filterInvoices` | Filter invoices | `$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$status: InvoiceStatus`
`$issueDateFrom: Timestamp`
`$issueDateTo: Timestamp`
`$dueDateFrom: Timestamp`
`$dueDateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listOverdueInvoices` | List overdue invoices | `$now: Timestamp!`
`$offset: Int`
`$limit: Int` | `invoices` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createInvoice` | Create invoice | `$status: InvoiceStatus!`
`$vendorId: UUID!`
`$businessId: UUID!`
`$orderId: UUID!`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String!`
`$issueDate: Timestamp!`
`$dueDate: Timestamp!`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float!`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoice_insert` | +| `updateInvoice` | Update invoice | `$id: UUID!`
`$status: InvoiceStatus`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int`
`$disputedItems: Any`
`$disputeReason: String`
`$disputeDetails: String` | `invoice_update` | +| `deleteInvoice` | Delete invoice | `$id: UUID!` | `invoice_delete` | + +## invoiceTemplate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listInvoiceTemplates` | List invoice templates | `$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `getInvoiceTemplateById` | Get invoice template by id | `$id: UUID!` | `invoiceTemplate` | +| `listInvoiceTemplatesByOwnerId` | List invoice templates by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByVendorId` | List invoice templates by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByBusinessId` | List invoice templates by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByOrderId` | List invoice templates by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `searchInvoiceTemplatesByOwnerAndName` | Search invoice templates by owner and name | `$ownerId: UUID!`
`$name: String!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createInvoiceTemplate` | Create invoice template | `$name: String!`
`$ownerId: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_insert` | +| `updateInvoiceTemplate` | Update invoice template | `$id: UUID!`
`$name: String`
`$ownerId: UUID`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_update` | +| `deleteInvoiceTemplate` | Delete invoice template | `$id: UUID!` | `invoiceTemplate_delete` | + +## level + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listLevels` | List levels | โ€” | `levels` | +| `getLevelById` | Get level by id | `$id: UUID!` | `level` | +| `filterLevels` | Filter levels | `$name: String`
`$xpRequired: Int` | `levels` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createLevel` | Create level | `$name: String!`
`$xpRequired: Int!`
`$icon: String`
`$colors: Any` | `level_insert` | +| `updateLevel` | Update level | `$id: UUID!`
`$name: String`
`$xpRequired: Int`
`$icon: String`
`$colors: Any` | `level_update` | +| `deleteLevel` | Delete level | `$id: UUID!` | `level_delete` | + +## memberTask + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getMyTasks` | Get my tasks | `$teamMemberId: UUID!` | `memberTasks` | +| `getMemberTaskByIdKey` | Get member task by id key | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask` | +| `getMemberTasksByTaskId` | Get member tasks by task id | `$taskId: UUID!` | `memberTasks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createMemberTask` | Create member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_insert` | +| `deleteMemberTask` | Delete member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_delete` | + +## message + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listMessages` | List messages | โ€” | `messages` | +| `getMessageById` | Get message by id | `$id: UUID!` | `message` | +| `getMessagesByConversationId` | Get messages by conversation id | `$conversationId: UUID!` | `messages` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createMessage` | Create message | `$conversationId: UUID!`
`$senderId: String!`
`$content: String!`
`$isSystem: Boolean` | `message_insert` | +| `updateMessage` | Update message | `$id: UUID!`
`$conversationId: UUID`
`$senderId: String`
`$content: String`
`$isSystem: Boolean` | `message_update` | +| `deleteMessage` | Delete message | `$id: UUID!` | `message_delete` | + +## order + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listOrders` | List orders | `$offset: Int`
`$limit: Int` | `orders` | +| `getOrderById` | Get order by id | `$id: UUID!` | `order` | +| `getOrdersByBusinessId` | Get orders by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByVendorId` | Get orders by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByStatus` | Get orders by status | `$status: OrderStatus!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByDateRange` | Get orders by date range | `$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getRapidOrders` | Get rapid orders | `$offset: Int`
`$limit: Int` | `orders` | +| `listOrdersByBusinessAndTeamHub` | List orders by business and team hub | `$businessId: UUID!`
`$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createOrder` | Create order | `$vendorId: UUID`
`$businessId: UUID!`
`$orderType: OrderType!`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$duration: OrderDuration`
`$lunchBreak: Int`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentStartDate: Timestamp`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_insert` | +| `updateOrder` | Update order | `$id: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_update` | +| `deleteOrder` | Delete order | `$id: UUID!` | `order_delete` | + +## recentPayment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRecentPayments` | List recent payments | `$offset: Int`
`$limit: Int` | `recentPayments` | +| `getRecentPaymentById` | Get recent payment by id | `$id: UUID!` | `recentPayment` | +| `listRecentPaymentsByStaffId` | List recent payments by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByApplicationId` | List recent payments by application id | `$applicationId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceId` | List recent payments by invoice id | `$invoiceId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByStatus` | List recent payments by status | `$status: RecentPaymentStatus!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceIds` | List recent payments by invoice ids | `$invoiceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByBusinessId` | List recent payments by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRecentPayment` | Create recent payment | `$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID!`
`$applicationId: UUID!`
`$invoiceId: UUID!` | `recentPayment_insert` | +| `updateRecentPayment` | Update recent payment | `$id: UUID!`
`$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID`
`$applicationId: UUID`
`$invoiceId: UUID` | `recentPayment_update` | +| `deleteRecentPayment` | Delete recent payment | `$id: UUID!` | `recentPayment_delete` | + +## reports + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listShiftsForCoverage` | List shifts for coverage | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForCoverage` | List applications for coverage | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForDailyOpsByBusiness` | List shifts for daily ops by business | `$businessId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listShiftsForDailyOpsByVendor` | List shifts for daily ops by vendor | `$vendorId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listApplicationsForDailyOps` | List applications for daily ops | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForForecastByBusiness` | List shifts for forecast by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForForecastByVendor` | List shifts for forecast by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByBusiness` | List shifts for no show range by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByVendor` | List shifts for no show range by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForNoShowRange` | List applications for no show range | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForNoShowReport` | List staff for no show report | `$staffIds: [UUID!]!` | `staffs` | +| `listInvoicesForSpendByBusiness` | List invoices for spend by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByVendor` | List invoices for spend by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByOrder` | List invoices for spend by order | `$orderId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listTimesheetsForSpend` | List timesheets for spend | `$startTime: Timestamp!`
`$endTime: Timestamp!` | `shiftRoles` | +| `listShiftsForPerformanceByBusiness` | List shifts for performance by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForPerformanceByVendor` | List shifts for performance by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForPerformance` | List applications for performance | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForPerformance` | List staff for performance | `$staffIds: [UUID!]!` | `staffs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| โ€” | โ€” | โ€” | โ€” | + +Notes: Used by Reports. + +## role + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRoles` | List roles | โ€” | `roles` | +| `getRoleById` | Get role by id | `$id: UUID!` | `role` | +| `listRolesByVendorId` | List roles by vendor id | `$vendorId: UUID!` | `roles` | +| `listRolesByroleCategoryId` | List roles byrole category id | `$roleCategoryId: UUID!` | `roles` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRole` | Create role | `$name: String!`
`$costPerHour: Float!`
`$vendorId: UUID!`
`$roleCategoryId: UUID!` | `role_insert` | +| `updateRole` | Update role | `$id: UUID!`
`$name: String`
`$costPerHour: Float`
`$roleCategoryId: UUID!` | `role_update` | +| `deleteRole` | Delete role | `$id: UUID!` | `role_delete` | + +## roleCategory + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRoleCategories` | List role categories | โ€” | `roleCategories` | +| `getRoleCategoryById` | Get role category by id | `$id: UUID!` | `roleCategory` | +| `getRoleCategoriesByCategory` | Get role categories by category | `$category: RoleCategoryType!` | `roleCategories` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRoleCategory` | Create role category | `$roleName: String!`
`$category: RoleCategoryType!` | `roleCategory_insert` | +| `updateRoleCategory` | Update role category | `$id: UUID!`
`$roleName: String`
`$category: RoleCategoryType` | `roleCategory_update` | +| `deleteRoleCategory` | Delete role category | `$id: UUID!` | `roleCategory_delete` | + +## shift + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listShifts` | List shifts | `$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftById` | Get shift by id | `$id: UUID!` | `shift` | +| `filterShifts` | Filter shifts | `$status: ShiftStatus`
`$orderId: UUID`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByBusinessId` | Get shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByVendorId` | Get shifts by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createShift` | Create shift | `$title: String!`
`$orderId: UUID!`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int`
`$createdBy: String` | `shift_insert` | +| `updateShift` | Update shift | `$id: UUID!`
`$title: String`
`$orderId: UUID`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int` | `shift_update` | +| `deleteShift` | Delete shift | `$id: UUID!` | `shift_delete` | + +## shiftRole + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getShiftRoleById` | Get shift role by id | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole` | +| `listShiftRolesByShiftId` | List shift roles by shift id | `$shiftId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByRoleId` | List shift roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByShiftIdAndTimeRange` | List shift roles by shift id and time range | `$shiftId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByVendorId` | List shift roles by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDateRange` | List shift roles by business and date range | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int`
`$status: ShiftStatus` | `shiftRoles` | +| `listShiftRolesByBusinessAndOrder` | List shift roles by business and order | `$businessId: UUID!`
`$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessDateRangeCompletedOrders` | List shift roles by business date range completed orders | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDatesSummary` | List shift roles by business and dates summary | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `getCompletedShiftsByBusinessId` | Get completed shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp!`
`$dateTo: Timestamp!`
`$offset: Int`
`$limit: Int` | `shifts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createShiftRole` | Create shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int!`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_insert` | +| `updateShiftRole` | Update shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_update` | +| `deleteShiftRole` | Delete shift role | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole_delete` | + +## staff + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaff` | List staff | โ€” | `staffs` | +| `getStaffById` | Get staff by id | `$id: UUID!` | `staff` | +| `getStaffByUserId` | Get staff by user id | `$userId: String!` | `staffs` | +| `filterStaff` | Filter staff | `$ownerId: UUID`
`$fullName: String`
`$level: String`
`$email: String` | `staffs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateStaff` | Create staff | `$userId: String!`
`$fullName: String!`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_insert` | +| `UpdateStaff` | Update staff | `$id: UUID!`
`$userId: String`
`$fullName: String`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_update` | +| `DeleteStaff` | Delete staff | `$id: UUID!` | `staff_delete` | + +## staffAvailability + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffAvailabilities` | List staff availabilities | `$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `listStaffAvailabilitiesByStaffId` | List staff availabilities by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `getStaffAvailabilityByKey` | Get staff availability by key | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability` | +| `listStaffAvailabilitiesByDay` | List staff availabilities by day | `$day: DayOfWeek!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffAvailability` | Create staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_insert` | +| `updateStaffAvailability` | Update staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_update` | +| `deleteStaffAvailability` | Delete staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability_delete` | + +## staffAvailabilityStats + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffAvailabilityStats` | List staff availability stats | `$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | +| `getStaffAvailabilityStatsByStaffId` | Get staff availability stats by staff id | `$staffId: UUID!` | `staffAvailabilityStats` | +| `filterStaffAvailabilityStats` | Filter staff availability stats | `$needWorkIndexMin: Int`
`$needWorkIndexMax: Int`
`$utilizationMin: Int`
`$utilizationMax: Int`
`$acceptanceRateMin: Int`
`$acceptanceRateMax: Int`
`$lastShiftAfter: Timestamp`
`$lastShiftBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffAvailabilityStats` | Create staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_insert` | +| `updateStaffAvailabilityStats` | Update staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_update` | +| `deleteStaffAvailabilityStats` | Delete staff availability stats | `$staffId: UUID!` | `staffAvailabilityStats_delete` | + +## staffCourse + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getStaffCourseById` | Get staff course by id | `$id: UUID!` | `staffCourse` | +| `listStaffCoursesByStaffId` | List staff courses by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `listStaffCoursesByCourseId` | List staff courses by course id | `$courseId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `getStaffCourseByStaffAndCourse` | Get staff course by staff and course | `$staffId: UUID!`
`$courseId: UUID!` | `staffCourses` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffCourse` | Create staff course | `$staffId: UUID!`
`$courseId: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_insert` | +| `updateStaffCourse` | Update staff course | `$id: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_update` | +| `deleteStaffCourse` | Delete staff course | `$id: UUID!` | `staffCourse_delete` | + +## staffDocument + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getStaffDocumentByKey` | Get staff document by key | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument` | +| `listStaffDocumentsByStaffId` | List staff documents by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByDocumentType` | List staff documents by document type | `$documentType: DocumentType!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByStatus` | List staff documents by status | `$status: DocumentStatus!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffDocument` | Create staff document | `$staffId: UUID!`
`$staffName: String!`
`$documentId: UUID!`
`$status: DocumentStatus!`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_insert` | +| `updateStaffDocument` | Update staff document | `$staffId: UUID!`
`$documentId: UUID!`
`$status: DocumentStatus`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_update` | +| `deleteStaffDocument` | Delete staff document | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument_delete` | + +## staffRole + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffRoles` | List staff roles | `$offset: Int`
`$limit: Int` | `staffRoles` | +| `getStaffRoleByKey` | Get staff role by key | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole` | +| `listStaffRolesByStaffId` | List staff roles by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `listStaffRolesByRoleId` | List staff roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `filterStaffRoles` | Filter staff roles | `$staffId: UUID`
`$roleId: UUID`
`$offset: Int`
`$limit: Int` | `staffRoles` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffRole` | Create staff role | `$staffId: UUID!`
`$roleId: UUID!`
`$roleType: RoleType` | `staffRole_insert` | +| `deleteStaffRole` | Delete staff role | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole_delete` | + +## task + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTasks` | List tasks | โ€” | `tasks` | +| `getTaskById` | Get task by id | `$id: UUID!` | `task` | +| `getTasksByOwnerId` | Get tasks by owner id | `$ownerId: UUID!` | `tasks` | +| `filterTasks` | Filter tasks | `$status: TaskStatus`
`$priority: TaskPriority` | `tasks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTask` | Create task | `$taskName: String!`
`$description: String`
`$priority: TaskPriority!`
`$status: TaskStatus!`
`$dueDate: Timestamp`
`$progress: Int`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any`
`$ownerId:UUID!` | `task_insert` | +| `updateTask` | Update task | `$id: UUID!`
`$taskName: String`
`$description: String`
`$priority: TaskPriority`
`$status: TaskStatus`
`$dueDate: Timestamp`
`$progress: Int`
`$assignedMembers: Any`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any` | `task_update` | +| `deleteTask` | Delete task | `$id: UUID!` | `task_delete` | + +## task_comment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTaskComments` | List task comments | โ€” | `taskComments` | +| `getTaskCommentById` | Get task comment by id | `$id: UUID!` | `taskComment` | +| `getTaskCommentsByTaskId` | Get task comments by task id | `$taskId: UUID!` | `taskComments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTaskComment` | Create task comment | `$taskId: UUID!`
`$teamMemberId: UUID!`
`$comment: String!`
`$isSystem: Boolean` | `taskComment_insert` | +| `updateTaskComment` | Update task comment | `$id: UUID!`
`$comment: String`
`$isSystem: Boolean` | `taskComment_update` | +| `deleteTaskComment` | Delete task comment | `$id: UUID!` | `taskComment_delete` | + +## taxForm + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTaxForms` | List tax forms | `$offset: Int`
`$limit: Int` | `taxForms` | +| `getTaxFormById` | Get tax form by id | `$id: UUID!` | `taxForm` | +| `getTaxFormsByStaffId` | Get tax forms by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `taxForms` | +| `listTaxFormsWhere` | List tax forms where | `$formType: TaxFormType`
`$status: TaxFormStatus`
`$staffId: UUID`
`$offset: Int`
`$limit: Int` | `taxForms` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTaxForm` | Create tax form | `$formType: TaxFormType!`
`$firstName: String!`
`$lastName: String!`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int!`
`$email: String`
`$phone: String`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus!`
`$staffId: UUID!`
`$createdBy: String` | `taxForm_insert` | +| `updateTaxForm` | Update tax form | `$id: UUID!`
`$formType: TaxFormType`
`$firstName: String`
`$lastName: String`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int`
`$email: String`
`$phone: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus` | `taxForm_update` | +| `deleteTaxForm` | Delete tax form | `$id: UUID!` | `taxForm_delete` | + +## team + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeams` | List teams | โ€” | `teams` | +| `getTeamById` | Get team by id | `$id: UUID!` | `team` | +| `getTeamsByOwnerId` | Get teams by owner id | `$ownerId: UUID!` | `teams` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeam` | Create team | `$teamName: String!`
`$ownerId: UUID!`
`$ownerName: String!`
`$ownerRole: String!`
`$email: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_insert` | +| `updateTeam` | Update team | `$id: UUID!`
`$teamName: String`
`$ownerName: String`
`$ownerRole: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_update` | +| `deleteTeam` | Delete team | `$id: UUID!` | `team_delete` | + +## teamHub + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamHubs` | List team hubs | `$offset: Int`
`$limit: Int` | `teamHubs` | +| `getTeamHubById` | Get team hub by id | `$id: UUID!` | `teamHub` | +| `getTeamHubsByTeamId` | Get team hubs by team id | `$teamId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | +| `listTeamHubsByOwnerId` | List team hubs by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamHub` | Create team hub | `$teamId: UUID!`
`$hubName: String!`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_insert` | +| `updateTeamHub` | Update team hub | `$id: UUID!`
`$teamId: UUID`
`$hubName: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_update` | +| `deleteTeamHub` | Delete team hub | `$id: UUID!` | `teamHub_delete` | + +## teamHudDeparment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamHudDepartments` | List team hud departments | `$offset: Int`
`$limit: Int` | `teamHudDepartments` | +| `getTeamHudDepartmentById` | Get team hud department by id | `$id: UUID!` | `teamHudDepartment` | +| `listTeamHudDepartmentsByTeamHubId` | List team hud departments by team hub id | `$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHudDepartments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamHudDepartment` | Create team hud department | `$name: String!`
`$costCenter: String`
`$teamHubId: UUID!` | `teamHudDepartment_insert` | +| `updateTeamHudDepartment` | Update team hud department | `$id: UUID!`
`$name: String`
`$costCenter: String`
`$teamHubId: UUID` | `teamHudDepartment_update` | +| `deleteTeamHudDepartment` | Delete team hud department | `$id: UUID!` | `teamHudDepartment_delete` | + +## teamMember + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamMembers` | List team members | โ€” | `teamMembers` | +| `getTeamMemberById` | Get team member by id | `$id: UUID!` | `teamMember` | +| `getTeamMembersByTeamId` | Get team members by team id | `$teamId: UUID!` | `teamMembers` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamMember` | Create team member | `$teamId: UUID!`
`$role: TeamMemberRole!`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$userId: String!`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_insert` | +| `updateTeamMember` | Update team member | `$id: UUID!`
`$role: TeamMemberRole`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_update` | +| `updateTeamMemberInviteStatus` | Update team member invite status | `$id: UUID!`
`$inviteStatus: TeamMemberInviteStatus!` | `teamMember_update` | +| `acceptInviteByCode` | Accept invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `cancelInviteByCode` | Cancel invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `deleteTeamMember` | Delete team member | `$id: UUID!` | `teamMember_delete` | + +## user + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listUsers` | List users | โ€” | `users` | +| `getUserById` | Get user by id | `$id: String!` | `user` | +| `filterUsers` | Filter users | `$id: String`
`$email: String`
`$role: UserBaseRole`
`$userRole: String` | `users` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateUser` | Create user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole!`
`$userRole: String`
`$photoUrl: String` | `user_insert` | +| `UpdateUser` | Update user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole`
`$userRole: String`
`$photoUrl: String` | `user_update` | +| `DeleteUser` | Delete user | `$id: String!` | `user_delete` | + +## userConversation + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listUserConversations` | List user conversations | `$offset: Int`
`$limit: Int` | `userConversations` | +| `getUserConversationByKey` | Get user conversation by key | `$conversationId: UUID!`
`$userId: String!` | `userConversation` | +| `listUserConversationsByUserId` | List user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUnreadUserConversationsByUserId` | List unread user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUserConversationsByConversationId` | List user conversations by conversation id | `$conversationId: UUID!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `filterUserConversations` | Filter user conversations | `$userId: String`
`$conversationId: UUID`
`$unreadMin: Int`
`$unreadMax: Int`
`$lastReadAfter: Timestamp`
`$lastReadBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `userConversations` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createUserConversation` | Create user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_insert` | +| `updateUserConversation` | Update user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `markConversationAsRead` | Mark conversation as read | `$conversationId: UUID!`
`$userId: String!`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `incrementUnreadForUser` | Increment unread for user | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int!` | `userConversation_update` | +| `deleteUserConversation` | Delete user conversation | `$conversationId: UUID!`
`$userId: String!` | `userConversation_delete` | + +## vendor + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getVendorById` | Get vendor by id | `$id: UUID!` | `vendor` | +| `getVendorByUserId` | Get vendor by user id | `$userId: String!` | `vendors` | +| `listVendors` | List vendors | โ€” | `vendors` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendor` | Create vendor | `$userId: String!`
`$companyName: String!`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_insert` | +| `updateVendor` | Update vendor | `$id: UUID!`
`$companyName: String`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_update` | +| `deleteVendor` | Delete vendor | `$id: UUID!` | `vendor_delete` | + +## vendorBenefitPlan + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listVendorBenefitPlans` | List vendor benefit plans | `$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `getVendorBenefitPlanById` | Get vendor benefit plan by id | `$id: UUID!` | `vendorBenefitPlan` | +| `listVendorBenefitPlansByVendorId` | List vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `listActiveVendorBenefitPlansByVendorId` | List active vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `filterVendorBenefitPlans` | Filter vendor benefit plans | `$vendorId: UUID`
`$title: String`
`$isActive: Boolean`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendorBenefitPlan` | Create vendor benefit plan | `$vendorId: UUID!`
`$title: String!`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_insert` | +| `updateVendorBenefitPlan` | Update vendor benefit plan | `$id: UUID!`
`$vendorId: UUID`
`$title: String`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_update` | +| `deleteVendorBenefitPlan` | Delete vendor benefit plan | `$id: UUID!` | `vendorBenefitPlan_delete` | + +## vendorRate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listVendorRates` | List vendor rates | โ€” | `vendorRates` | +| `getVendorRateById` | Get vendor rate by id | `$id: UUID!` | `vendorRate` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendorRate` | Create vendor rate | `$vendorId: UUID!`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_insert` | +| `updateVendorRate` | Update vendor rate | `$id: UUID!`
`$vendorId: UUID`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_update` | +| `deleteVendorRate` | Delete vendor rate | `$id: UUID!` | `vendorRate_delete` | + +## workForce + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getWorkforceById` | Get workforce by id | `$id: UUID!` | `workforce` | +| `getWorkforceByVendorAndStaff` | Get workforce by vendor and staff | `$vendorId: UUID!`
`$staffId: UUID!` | `workforces` | +| `listWorkforceByVendorId` | List workforce by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `listWorkforceByStaffId` | List workforce by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `getWorkforceByVendorAndNumber` | Get workforce by vendor and number | `$vendorId: UUID!`
`$workforceNumber: String!` | `workforces` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createWorkforce` | Create workforce | `$vendorId: UUID!`
`$staffId: UUID!`
`$workforceNumber: String!`
`$employmentType: WorkforceEmploymentType` | `workforce_insert` | +| `updateWorkforce` | Update workforce | `$id: UUID!`
`$workforceNumber: String`
`$employmentType: WorkforceEmploymentType`
`$status: WorkforceStatus` | `workforce_update` | +| `deactivateWorkforce` | Deactivate workforce | `$id: UUID!` | `workforce_update` | diff --git a/internal/launchpad/assets/documents/documents-config.json b/internal/launchpad/assets/documents/documents-config.json index 16d16ebf..6219f696 100644 --- a/internal/launchpad/assets/documents/documents-config.json +++ b/internal/launchpad/assets/documents/documents-config.json @@ -65,10 +65,10 @@ }, { "title": "Dataconnect guide", - "path": "docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md" + "path": "./assets/documents/data connect/backend_manual.md" }, { "title": "Schema Dataconnect guide", - "path": "docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md" + "path": "./assets/documents/data connect/schema_dataconnect_guide.md" } ] diff --git a/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md b/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md index f035e224..174a0540 100644 --- a/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md +++ b/internal/launchpad/assets/documents/prototype/client-mobile-application/architecture.md @@ -59,7 +59,7 @@ The application is broken down into several key functional modules: | Component | Primary Responsibility | Example Task | | :--- | :--- | :--- | -| **Router (GoRouter)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. | +| **Router (Flutter Modular)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. | | **Screens (UI)** | Displaying information | Renders the "Create Order" form and captures the user's input for date and time. | | **Providers (Riverpod)** | Data management & State | Holds the list of today's active shifts so multiple screens can access it without reloading. | | **Widgets** | Reusable UI building blocks | A "Shift Card" widget that displays shift details effectively, used in multiple lists throughout the app. | @@ -91,7 +91,7 @@ While currently operating as a high-fidelity prototype with mock data, the archi ## 8. Key Design Decisions * **Flutter Framework:** chosen for its ability to produce high-performance, native-feeling apps for both iOS and Android from a single codebase, reducing development time and cost. -* **GoRouter for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this. +* **Flutter Modular for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this. * **Riverpod for State Management:** A robust solution that catches programming errors at compile-time (while writing code) rather than run-time (while using the app), increasing app stability. * **Mock Data Services:** The decision to use extensive mock data allows for rapid UI/UX iteration and testing of business flows without waiting for the full backend infrastructure to be built. @@ -102,7 +102,7 @@ flowchart TD direction TB subgraph PresentationLayer["Presentation Layer (UI)"] direction TB - Router["GoRouter Navigation"] + Router["Flutter Modular Navigation"] subgraph FeatureModules["Feature Modules"] AuthUI["Auth Screens"] DashUI["Dashboard & Home"] diff --git a/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md b/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md index 0c2ffbff..07c385b7 100644 --- a/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md +++ b/internal/launchpad/assets/documents/prototype/staff-mobile-application/architecture.md @@ -98,7 +98,7 @@ flowchart TD direction TB subgraph PresentationLayer["Presentation Layer (UI)"] direction TB - Router["GoRouter Navigation"] + Router["Flutter Modular Navigation"] subgraph FeatureModules["Feature Modules"] AuthUI["Auth & Onboarding"] MarketUI["Marketplace & Jobs"] diff --git a/internal/launchpad/package-lock.json b/internal/launchpad/package-lock.json new file mode 100644 index 00000000..86416bc9 --- /dev/null +++ b/internal/launchpad/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "launchpad", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/internal/launchpad/prototypes/mobile/client/.keep b/internal/launchpad/prototypes/mobile/client/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/launchpad/prototypes/mobile/staff/.keep b/internal/launchpad/prototypes/mobile/staff/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/scripts/create_issues.py b/scripts/create_issues.py new file mode 100644 index 00000000..bbe0b071 --- /dev/null +++ b/scripts/create_issues.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +import subprocess +import os +import re +import argparse + +# --- Configuration --- +INPUT_FILE = "issues-to-create.md" +DEFAULT_PROJECT_TITLE = None +DEFAULT_MILESTONE = "Milestone 4" +# --- + +def parse_issues(content): + """Parse issue blocks from markdown content. + + Each issue block starts with a '# Title' line, followed by an optional + 'Labels:' metadata line, then the body. Milestone is set globally, not per-issue. + """ + issue_blocks = re.split(r'\n(?=#\s)', content) + issues = [] + + for block in issue_blocks: + if not block.strip(): + continue + + lines = block.strip().split('\n') + + # Title: strip leading '#' characters and whitespace + title = re.sub(r'^#+\s*', '', lines[0]).strip() + + labels_line = "" + body_start_index = len(lines) # default: no body + + # Only 'Labels:' is parsed from the markdown; milestone is global + for i, line in enumerate(lines[1:], start=1): + stripped = line.strip() + if stripped.lower().startswith('labels:'): + labels_line = stripped.split(':', 1)[1].strip() + elif stripped == "": + continue # skip blank separator lines in the header + else: + body_start_index = i + break + + body = "\n".join(lines[body_start_index:]).strip() + labels = [label.strip() for label in labels_line.split(',') if label.strip()] + + if not title: + print("โš ๏ธ Skipping block with no title.") + continue + + issues.append({ + "title": title, + "body": body, + "labels": labels, + }) + + return issues + + +def main(): + parser = argparse.ArgumentParser( + description="Bulk create GitHub issues from a markdown file.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Input file format (issues-to-create.md): +----------------------------------------- +# Issue Title One +Labels: bug, enhancement + +This is the body of the first issue. +It can span multiple lines. + +# Issue Title Two +Labels: documentation + +Body of the second issue. +----------------------------------------- +All issues share the same project and milestone, configured at the top of this script +or passed via --project and --milestone flags. + """ + ) + parser.add_argument( + "--file", "-f", + default=INPUT_FILE, + help=f"Path to the markdown input file (default: {INPUT_FILE})" + ) + parser.add_argument( + "--project", "-p", + default=DEFAULT_PROJECT_TITLE, + help=f"GitHub Project title for all issues (default: {DEFAULT_PROJECT_TITLE})" + ) + parser.add_argument( + "--milestone", "-m", + default=DEFAULT_MILESTONE, + help=f"Milestone to assign to all issues (default: {DEFAULT_MILESTONE})" + ) + parser.add_argument( + "--no-project", + action="store_true", + help="Do not add issues to any project." + ) + parser.add_argument( + "--no-milestone", + action="store_true", + help="Do not assign a milestone to any issue." + ) + parser.add_argument( + "--repo", "-r", + default=None, + help="Target GitHub repo in OWNER/REPO format (uses gh default if not set)." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse the file and print issues without creating them." + ) + args = parser.parse_args() + + input_file = args.file + project_title = args.project if not args.no_project else None + milestone = args.milestone if not args.no_milestone else None + + print("๐Ÿš€ Bulk GitHub Issue Creator") + print("=" * 40) + print(f" Input file: {input_file}") + print(f" Project: {project_title or '(none)'}") + print(f" Milestone: {milestone or '(none)'}") + if args.repo: + print(f" Repo: {args.repo}") + if args.dry_run: + print(" Mode: DRY RUN (no issues will be created)") + print("=" * 40) + + # --- Preflight checks --- + if subprocess.run(["which", "gh"], capture_output=True).returncode != 0: + print("โŒ ERROR: GitHub CLI ('gh') is not installed or not in PATH.") + print(" Install it from: https://cli.github.com/") + exit(1) + + if not os.path.exists(input_file): + print(f"โŒ ERROR: Input file '{input_file}' not found.") + exit(1) + + print("โœ… Preflight checks passed.\n") + + # --- Parse --- + print(f"๐Ÿ“„ Parsing '{input_file}'...") + with open(input_file, 'r') as f: + content = f.read() + + issues = parse_issues(content) + + if not issues: + print("โš ๏ธ No issues found in the input file. Check the format.") + exit(0) + + print(f" Found {len(issues)} issue(s) to create.\n") + + # --- Create --- + success_count = 0 + fail_count = 0 + + for idx, issue in enumerate(issues, start=1): + print(f"[{idx}/{len(issues)}] {issue['title']}") + if issue['labels']: + print(f" Labels: {', '.join(issue['labels'])}") + print(f" Milestone: {milestone or '(none)'}") + print(f" Project: {project_title or '(none)'}") + + if args.dry_run: + print(" (dry-run โ€” skipping creation)\n") + continue + + command = ["gh", "issue", "create"] + if args.repo: + command.extend(["--repo", args.repo]) + command.extend(["--title", issue["title"]]) + command.extend(["--body", issue["body"] or " "]) # gh requires non-empty body + + if project_title: + command.extend(["--project", project_title]) + if milestone: + command.extend(["--milestone", milestone]) + for label in issue["labels"]: + command.extend(["--label", label]) + + try: + result = subprocess.run(command, check=True, text=True, capture_output=True) + print(f" โœ… Created: {result.stdout.strip()}") + success_count += 1 + except subprocess.CalledProcessError as e: + print(f" โŒ Failed: {e.stderr.strip()}") + fail_count += 1 + + print() + + # --- Summary --- + print("=" * 40) + if args.dry_run: + print(f"๐Ÿ” Dry run complete. {len(issues)} issue(s) parsed, none created.") + else: + print(f"๐ŸŽ‰ Done! {success_count} created, {fail_count} failed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/issues-to-create.md b/scripts/issues-to-create.md new file mode 100644 index 00000000..8172f5bf --- /dev/null +++ b/scripts/issues-to-create.md @@ -0,0 +1,27 @@ +# +Labels: + + + +## Scope + +### +- + +## +- [ ] + +------- + +# +Labels: + + + +## Scope + +### +- + +## +- [ ]