Merge remote-tracking branch 'origin/dev' into codex/feat-architecture-lead-bootstrap

This commit is contained in:
zouantchaw
2026-02-26 09:45:09 -05:00
809 changed files with 44573 additions and 7915 deletions

248
.github/workflows/mobile-ci.yml vendored Normal file
View File

@@ -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<<EOF" >> $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

3
.gitignore vendored
View File

@@ -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/

View File

@@ -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.

View File

@@ -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) {

View File

@@ -6,6 +6,12 @@
#import "GeneratedPluginRegistrant.h"
#if __has_include(<file_picker/FilePickerPlugin.h>)
#import <file_picker/FilePickerPlugin.h>
#else
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@@ -24,6 +30,12 @@
@import firebase_core;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@@ -39,9 +51,11 @@
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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"]];
}

View File

@@ -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 {
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,
);

View File

@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -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"))

View File

@@ -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 |

View File

@@ -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"

View File

@@ -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"

View File

@@ -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:

View File

@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
url_launcher_windows

View File

@@ -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) {

View File

@@ -6,6 +6,12 @@
#import "GeneratedPluginRegistrant.h"
#if __has_include(<file_picker/FilePickerPlugin.h>)
#import <file_picker/FilePickerPlugin.h>
#else
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@@ -36,6 +42,12 @@
@import google_maps_flutter_ios;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#else
@@ -57,11 +69,13 @@
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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"]];

View File

@@ -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 {
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: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
allowedRoles: <String>[
'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<Module> get imports => <Module>[core_localization.LocalizationModule()];
List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];
@override
void routes(RouteManager r) {

View File

@@ -40,7 +40,7 @@ class _SessionListenerState extends State<SessionListener> {
debugPrint('[SessionListener] Initialized session listener');
}
void _handleSessionChange(SessionState state) {
Future<void> _handleSessionChange(SessionState state) async {
if (!mounted) return;
switch (state.type) {
@@ -65,6 +65,7 @@ class _SessionListenerState extends State<SessionListener> {
_sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Navigate to the main app
Modular.to.toStaffHome();
break;

View File

@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -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"))

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
@@ -13,6 +14,8 @@
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
geolocator_windows

View File

@@ -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"
}

View File

@@ -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';

View File

@@ -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',
);
}

View File

@@ -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<Dio>(() => DioClient());
// 2. Register the base API service
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
// 3. Register Core API Services (Orchestrators)
i.addSingleton<FileUploadService>(
() => FileUploadService(i.get<BaseApiService>()),
);
i.addSingleton<SignedUrlService>(
() => SignedUrlService(i.get<BaseApiService>()),
);
i.addSingleton<VerificationService>(
() => VerificationService(i.get<BaseApiService>()),
);
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
// 4. Register Device dependency
i.addSingleton<ImagePicker>(() => ImagePicker());
// 5. Register Device Services
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
i.addSingleton<FilePickerService>(FilePickerService.new);
i.addSingleton<DeviceFileUploadService>(
() => DeviceFileUploadService(
cameraService: i.get<CameraService>(),
galleryService: i.get<GalleryService>(),
apiUploadService: i.get<FileUploadService>(),
),
);
}
}

View File

@@ -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(),
);
}
}
}

View File

@@ -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<bool?> toHubDetails(Hub hub) {
return pushNamed<bool?>(
ClientPaths.hubDetails,
arguments: <String, dynamic>{'hub': hub},
);
}
/// Navigates to the page to add a new hub or edit an existing one.
Future<bool?> toEditHub({Hub? hub}) async {
return pushNamed<bool?>(
ClientPaths.editHub,
arguments: <String, dynamic>{'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: <String, DateTime>{'initialDate': date},
);
}
}

View File

@@ -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
// ==========================================================================

View File

@@ -41,6 +41,7 @@
/// final homePath = ClientPaths.home;
/// final shiftsPath = StaffPaths.shifts;
/// ```
library;
export 'client/route_paths.dart';
export 'client/navigator.dart';

View File

@@ -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<String, dynamic> args = <String, dynamic>{};
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: <String, dynamic>{
'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.

View File

@@ -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';

View File

@@ -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<ApiResponse> get(
String endpoint, {
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.get<dynamic>(
endpoint,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Performs a POST request to the specified [endpoint].
@override
Future<ApiResponse> post(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.post<dynamic>(
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<ApiResponse> put(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.put<dynamic>(
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<ApiResponse> patch(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.patch<dynamic>(
endpoint,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> 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<String, dynamic>) {
final Map<String, dynamic> body =
e.response!.data as Map<String, dynamic>;
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: <String, dynamic>{'exception': e.type.toString()},
);
}
/// Helper to parse the errors map from various possible formats.
Map<String, dynamic> _parseErrors(dynamic errors) {
if (errors is Map) {
return Map<String, dynamic>.from(errors);
}
return const <String, dynamic>{};
}
}

View File

@@ -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';
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'fileUri': fileUri,
'contentType': contentType,
'size': size,
'bucket': bucket,
'path': path,
'requestId': requestId,
};
}
}

View File

@@ -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<FileUploadResponse> 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(<String, dynamic>{
'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<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<String, dynamic> json) {
return LlmResponse(
result: json['result'] as Map<String, dynamic>,
model: json['model'] as String,
latencyMs: json['latencyMs'] as int,
requestId: json['requestId'] as String?,
);
}
/// The JSON result returned by the model.
final Map<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'result': result,
'model': model,
'latencyMs': latencyMs,
'requestId': requestId,
};
}
}

View File

@@ -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<LlmResponse> invokeLlm({
required String prompt,
Map<String, dynamic>? responseJsonSchema,
List<String>? fileUrls,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.invokeLlm,
data: <String, dynamic>{
'prompt': prompt,
if (responseJsonSchema != null)
'responseJsonSchema': responseJsonSchema,
if (fileUrls != null) 'fileUrls': fileUrls,
},
);
});
if (res.code.startsWith('2')) {
return LlmResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'signedUrl': signedUrl,
'expiresAt': expiresAt.toIso8601String(),
'requestId': requestId,
};
}
}

View File

@@ -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<SignedUrlResponse> createSignedUrl({
required String fileUri,
int expiresInSeconds = 300,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.createSignedUrl,
data: <String, dynamic>{
'fileUri': fileUri,
'expiresInSeconds': expiresInSeconds,
},
);
});
if (res.code.startsWith('2')) {
return SignedUrlResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic>
: 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<String, dynamic>? review;
/// The unique request ID from the server.
final String? requestId;
/// Converts the response to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'verificationId': verificationId,
'status': status.value,
'type': type,
'review': review,
'requestId': requestId,
};
}
}

View File

@@ -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<VerificationResponse> createVerification({
required String type,
required String subjectType,
required String subjectId,
required String fileUri,
Map<String, dynamic>? rules,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verifications,
data: <String, dynamic>{
'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<String, dynamic>);
}
throw Exception(res.message);
}
/// Polls the status of a specific verification.
Future<VerificationResponse> 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<String, dynamic>);
}
throw Exception(res.message);
}
/// Submits a manual review decision.
///
/// [decision] should be 'APPROVED' or 'REJECTED'.
Future<VerificationResponse> 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: <String, dynamic>{
'decision': decision,
if (note != null) 'note': note,
if (reasonCode != null) 'reasonCode': reasonCode,
},
);
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Retries a verification job that failed or needs re-processing.
Future<VerificationResponse> 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<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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(<Interceptor>[
AuthInterceptor(),
LogInterceptor(
requestBody: true,
responseBody: true,
), // Added for better debugging
]);
}
}

View File

@@ -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<void> 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);
}
}

View File

@@ -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<String?> takePhoto() async {
return action(() async {
final XFile? file = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
return file?.path;
});
}
}

View File

@@ -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<String?> pickFile({List<String>? 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;
});
}
}

View File

@@ -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<FileUploadResponse?> 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<FileUploadResponse?> 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,
);
});
}
}

View File

@@ -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<String?> pickImage() async {
return action(() async {
final XFile? file = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
return file?.path;
});
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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<void> saveLanguageCode(String languageCode) async {

View File

@@ -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() {

View File

@@ -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<Locale?> {
final LocaleRepositoryInterface _repository;
/// Creates a [GetLocaleUseCase] with the required [LocaleRepositoryInterface].
GetLocaleUseCase(this._repository);
final LocaleRepositoryInterface _repository;
@override
Future<Locale> call() {

View File

@@ -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<Locale> call() {

View File

@@ -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<Locale, void> {
final LocaleRepositoryInterface _repository;
/// Creates a [SetLocaleUseCase] with the required [LocaleRepositoryInterface].
SetLocaleUseCase(this._repository);
final LocaleRepositoryInterface _repository;
@override
Future<void> call(Locale input) {

View File

@@ -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 pageall 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."
}
}
}

View File

@@ -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';

View File

@@ -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<List<BusinessBankAccount>> getBankAccounts({required String businessId}) async {
return _service.run(() async {
final QueryResult<dc.GetAccountsByOwnerIdData, dc.GetAccountsByOwnerIdVariables> result = await _service.connector
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data.accounts.map(_mapBankAccount).toList();
});
}
@override
Future<double> getCurrentBillAmount({required String businessId}) async {
return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(0.0, (double sum, Invoice item) => sum + item.totalAmount);
});
}
@override
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> 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<List<Invoice>> getPendingInvoices({required String businessId}) async {
return _service.run(() async {
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) =>
i.status != InvoiceStatus.paid)
.toList();
});
}
@override
Future<List<InvoiceItem>> 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<dc.ListShiftRolesByBusinessAndDatesSummaryData, dc.ListShiftRolesByBusinessAndDatesSummaryVariables> result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles> shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) return <InvoiceItem>[];
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role in shiftRoles) {
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<void> approveInvoice({required String id}) async {
return _service.run(() async {
await _service.connector
.updateInvoice(id: id)
.status(dc.InvoiceStatus.APPROVED)
.execute();
});
}
@override
Future<void> 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<dynamic> rolesData = invoice.roles is List ? invoice.roles : [];
final List<InvoiceWorker> workers = rolesData.map((dynamic r) {
final Map<String, dynamic> role = r as Map<String, dynamic>;
// Handle various possible key naming conventions in the JSON data
final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
final double amount = (role['amount'] as num?)?.toDouble() ??
(role['totalValue'] as num?)?.toDouble() ?? 0.0;
final double hours = (role['hours'] as num?)?.toDouble() ??
(role['workHours'] as num?)?.toDouble() ??
(role['totalHours'] as num?)?.toDouble() ?? 0.0;
final double rate = (role['rate'] as num?)?.toDouble() ??
(role['hourlyRate'] as num?)?.toDouble() ?? 0.0;
final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
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<dynamic> roles) {
return roles.fold<double>(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,
);
}
}

View File

@@ -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<List<BusinessBankAccount>> getBankAccounts({required String businessId});
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount({required String businessId});
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory({required String businessId});
/// Fetches pending invoices (Open or Disputed).
Future<List<Invoice>> getPendingInvoices({required String businessId});
/// Fetches the breakdown of spending.
Future<List<InvoiceItem>> getSpendingBreakdown({
required String businessId,
required BillingPeriod period,
});
/// Approves an invoice.
Future<void> approveInvoice({required String id});
/// Disputes an invoice.
Future<void> disputeInvoice({required String id, required String reason});
}

View File

@@ -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<List<CoverageShift>> 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<dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final QueryResult<dc.ListStaffsApplicationsByBusinessForDayData, dc.ListStaffsApplicationsByBusinessForDayVariables> 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<CoverageShift> _mapCoverageShifts(
List<dynamic> shiftRoles,
List<dynamic> applications,
DateTime date,
) {
if (shiftRoles.isEmpty && applications.isEmpty) return <CoverageShift>[];
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
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: <CoverageWorker>[],
);
}
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: <CoverageWorker>[],
);
}
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<CoverageWorker> workers;
}

View File

@@ -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<List<CoverageShift>> getShiftsForDate({
required String businessId,
required DateTime date,
});
}

View File

@@ -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<List<Hub>> getHubs({required String businessId}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
final QueryResult<dc.GetTeamHubsByTeamIdData, dc.GetTeamHubsByTeamIdVariables> response = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
final QueryResult<
dc.ListTeamHudDepartmentsData,
dc.ListTeamHudDepartmentsVariables
>
deptsResult = await _service.connector.listTeamHudDepartments().execute();
final Map<String, dc.ListTeamHudDepartmentsTeamHudDepartments> hubToDept =
<String, dc.ListTeamHudDepartmentsTeamHudDepartments>{};
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<Hub> 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<dc.CreateTeamHubData, dc.CreateTeamHubVariables> 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<Hub> 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<dc.ListTeamHudDepartmentsByTeamHubIdTeamHudDepartments> 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<void> deleteHub({required String businessId, required String id}) async {
return _service.run(() async {
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, dc.ListOrdersByBusinessAndTeamHubVariables> 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<String> _getOrCreateTeamId(String businessId) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> 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<dc.CreateTeamData, dc.CreateTeamVariables> 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',
<String, dynamic>{
'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<String, dynamic> payload = json.decode(response.body) as Map<String, dynamic>;
if (payload['status'] != 'OK') return null;
final Map<String, dynamic>? result = payload['result'] as Map<String, dynamic>?;
final List<dynamic>? components = result?['address_components'] as List<dynamic>?;
if (components == null || components.isEmpty) return null;
String? streetNumber, route, city, state, country, zipCode;
for (var entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[];
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 = <String?>[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;
}

View File

@@ -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<List<Hub>> getHubs({required String businessId});
/// Creates a new hub.
Future<Hub> 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<Hub> 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<void> deleteHub({required String businessId, required String id});
}

View File

@@ -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<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForDailyOpsByBusinessData, dc.ListShiftsForDailyOpsByBusinessVariables> response = await _service.connector
.listShiftsForDailyOpsByBusiness(
businessId: id,
date: _service.toTimestamp(date),
)
.execute();
final List<dc.ListShiftsForDailyOpsByBusinessShifts> shifts = response.data.shifts;
final int scheduledShifts = shifts.length;
int workersConfirmed = 0;
int inProgressShifts = 0;
int completedShifts = 0;
final List<DailyOpsShift> dailyOpsShifts = <DailyOpsShift>[];
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<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> response = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = response.data.invoices;
double totalSpend = 0.0;
int paidInvoices = 0;
int pendingInvoices = 0;
int overdueInvoices = 0;
final List<SpendInvoice> spendInvoices = <SpendInvoice>[];
final Map<DateTime, double> dailyAggregates = <DateTime, double>{};
final Map<String, double> industryAggregates = <String, double>{};
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<DateTime, double> completeDailyAggregates = <DateTime, double>{};
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<SpendChartPoint> chartData = completeDailyAggregates.entries
.map((MapEntry<DateTime, double> e) => SpendChartPoint(date: e.key, amount: e.value))
.toList()
..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date));
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
.map((MapEntry<String, double> 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<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForCoverageData, dc.ListShiftsForCoverageVariables> response = await _service.connector
.listShiftsForCoverage(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForCoverageShifts> shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
final Map<DateTime, (int, int)> dailyStats = <DateTime, (int, int)>{};
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<CoverageDay> dailyCoverage = dailyStats.entries.map((MapEntry<DateTime, (int, int)> 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<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> response = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> shifts = response.data.shifts;
double projectedSpend = 0.0;
int projectedWorkers = 0;
double totalHours = 0.0;
final Map<DateTime, (double, int)> dailyStats = <DateTime, (double, int)>{};
// Weekly stats: index -> (cost, count, hours)
final Map<int, (double, int, double)> weeklyStats = <int, (double, int, double)>{
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<ForecastPoint> chartData = dailyStats.entries.map((MapEntry<DateTime, (double, int)> 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<ForecastWeek> weeklyBreakdown = <ForecastWeek>[];
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<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> response = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForPerformanceByBusinessShifts> 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>[
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<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForNoShowRangeByBusinessData, dc.ListShiftsForNoShowRangeByBusinessVariables> shiftsResponse = await _service.connector
.listShiftsForNoShowRangeByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<String> shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList();
if (shiftIds.isEmpty) {
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: <NoShowWorker>[]);
}
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
final List<String> 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: <NoShowWorker>[],
);
}
final QueryResult<dc.ListStaffForNoShowReportData, dc.ListStaffForNoShowReportVariables> staffResponse = await _service.connector
.listStaffForNoShowReport(staffIds: noShowStaffIds)
.execute();
final List<dc.ListStaffForNoShowReportStaffs> staffList = staffResponse.data.staffs;
final List<NoShowWorker> 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<ReportsSummary> 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<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> 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<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> perfResponse = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> invoicesResponse = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> forecastShifts = shiftsResponse.data.shifts;
final List<dc.ListShiftsForPerformanceByBusinessShifts> perfShifts = perfResponse.data.shifts;
final List<dc.ListInvoicesForSpendByBusinessInvoices> 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<String> shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList();
double noShowRate = 0;
if (shiftIds.isNotEmpty) {
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> 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,
);
});
}
}

View File

@@ -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<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
});
/// Fetches the spend report for a specific business and date range.
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the coverage report for a specific business and date range.
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the forecast report for a specific business and date range.
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the performance report for a specific business and date range.
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the no-show report for a specific business and date range.
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches a summary of all reports for a specific business and date range.
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
}

View File

@@ -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<List<Shift>> 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<List<Shift>> 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 <Shift>[];
final QueryResult<
dc.ListShiftRolesByVendorIdData,
dc.ListShiftRolesByVendorIdVariables
>
response = await _service.connector
.listShiftRolesByVendorId(vendorId: vendorId)
.execute();
final List<dc.ListShiftRolesByVendorIdShiftRoles> 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<String> appliedShiftIds = myAppsResponse.data.applications
.map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId)
.toSet();
final List<Shift> mappedShifts = <Shift>[];
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<ShiftSchedule>? 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<List<Shift>> 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 <Shift>[];
});
}
@override
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
}) async {
return _service.run(() async {
if (roleId != null && roleId.isNotEmpty) {
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
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<dc.GetShiftByIdData, dc.GetShiftByIdVariables> 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<void> 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<dc.GetShiftByIdData, dc.GetShiftByIdVariables>
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<dc.OrderType> orderTypeEnum =
initialShift.order.orderType;
final bool isMultiDay =
orderTypeEnum is dc.Known<dc.OrderType> &&
(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<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
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<String> 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<void> _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<void> acceptShift({required String shiftId, required String staffId}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.CONFIRMED,
);
}
@override
Future<void> declineShift({
required String shiftId,
required String staffId,
}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.REJECTED,
);
}
@override
Future<List<Shift>> getCancelledShifts({required String staffId}) async {
return _service.run(() async {
// Logic would go here to fetch by REJECTED status if needed
return <Shift>[];
});
}
@override
Future<List<Shift>> 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<Shift> shifts = <Shift>[];
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<Shift> _mapApplicationsToShifts(List<dynamic> 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<void> _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<ShiftSchedule>? _generateSchedules({
required String orderType,
required DateTime? startDate,
required DateTime? endDate,
required List<String>? recurringDays,
required List<String>? permanentDays,
required String startTime,
required String endTime,
}) {
if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null;
if (startDate == null || endDate == null) return null;
final List<String>? daysToInclude = orderType == 'RECURRING'
? recurringDays
: permanentDays;
if (daysToInclude == null || daysToInclude.isEmpty) return null;
final List<ShiftSchedule> schedules = <ShiftSchedule>[];
final Set<int> 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,
});
}

View File

@@ -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<List<Shift>> getMyShifts({
required String staffId,
required DateTime start,
required DateTime end,
});
/// Retrieves available shifts.
Future<List<Shift>> getAvailableShifts({
required String staffId,
String? query,
String? type,
});
/// Retrieves pending shift assignments for the current staff member.
Future<List<Shift>> getPendingAssignments({required String staffId});
/// Retrieves detailed information for a specific shift.
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
});
/// Applies for a specific open shift.
Future<void> applyForShift({
required String shiftId,
required String staffId,
bool isInstantBook = false,
String? roleId,
});
/// Accepts a pending shift assignment.
Future<void> acceptShift({
required String shiftId,
required String staffId,
});
/// Declines a pending shift assignment.
Future<void> declineShift({
required String shiftId,
required String staffId,
});
/// Retrieves cancelled shifts for the current staff member.
Future<List<Shift>> getCancelledShifts({required String staffId});
/// Retrieves historical (completed) shifts for the current staff member.
Future<List<Shift>> getHistoryShifts({required String staffId});
}

View File

@@ -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<bool> 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<dc.GetStaffProfileCompletionEmergencyContacts>
emergencyContacts = response.data.emergencyContacts;
final List<dc.GetStaffProfileCompletionTaxForms> taxForms =
response.data.taxForms;
return _isProfileComplete(staff, emergencyContacts, taxForms);
});
}
@override
Future<bool> 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<bool> 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<bool> 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<bool> 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<String>? skills = staff.skills;
final List<String>? 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<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
List<dc.GetStaffProfileCompletionTaxForms> taxForms,
) {
if (staff == null) return false;
final List<String>? skills = staff.skills;
final List<String>? 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<domain.Staff> getStaffProfile() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
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<List<domain.Benefit>> 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<List<domain.AttireItem>> getAttireOptions() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listAttireOptions().execute(),
_service.connector.getStaffAttire(staffId: staffId).execute(),
],
);
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
staffAttireRes =
results[1]
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
final List<dc.GetStaffAttireStaffAttires> 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<void> 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<dc.AttireVerificationStatus> status,
) {
if (status is dc.Unknown) {
return domain.AttireVerificationStatus.error;
}
final String name =
(status as dc.Known<dc.AttireVerificationStatus>).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<void> 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<void> signOut() async {
try {
await _service.auth.signOut();
_service.clearCache();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
}

View File

@@ -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<bool> getProfileCompletion();
/// Fetches personal information completion status.
///
/// Returns true if personal info (name, email, phone, locations) is complete.
Future<bool> getPersonalInfoCompletion();
/// Fetches emergency contacts completion status.
///
/// Returns true if at least one emergency contact exists.
Future<bool> getEmergencyContactsCompletion();
/// Fetches experience completion status.
///
/// Returns true if staff has industries or skills defined.
Future<bool> getExperienceCompletion();
/// Fetches tax forms completion status.
///
/// Returns true if at least one tax form exists.
Future<bool> 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<Staff> getStaffProfile();
/// Fetches the benefits for the current authenticated user.
///
/// Returns a list of [Benefit] entities.
Future<List<Benefit>> getBenefits();
/// Fetches the attire options for the current authenticated user.
///
/// Returns a list of [AttireItem] entities.
Future<List<AttireItem>> getAttireOptions();
/// Upserts staff attire photo information.
Future<void> 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<void> signOut();
/// Saves the staff profile information.
Future<void> saveStaffProfile({
String? firstName,
String? lastName,
String? bio,
String? profilePictureUrl,
});
}

View File

@@ -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<bool> {
/// 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<bool> call() => _repository.getEmergencyContactsCompletion();
}

View File

@@ -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<bool> {
/// 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<bool> call() => _repository.getExperienceCompletion();
}

View File

@@ -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<bool> {
/// 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<bool> call() => _repository.getPersonalInfoCompletion();
}

View File

@@ -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<bool> {
/// 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<bool> call() => _repository.getProfileCompletion();
}

View File

@@ -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<void, Staff> {
/// 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<Staff> call([void params]) => _repository.getStaffProfile();
}

View File

@@ -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<bool> {
/// 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<bool> call() => _repository.getTaxFormsCompletion();
}

View File

@@ -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<void> {
/// 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<void> call() => _repository.signOut();
}

View File

@@ -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>(DataConnectService.instance);
// Repositories
i.addLazySingleton<ReportsConnectorRepository>(
ReportsConnectorRepositoryImpl.new,
);
i.addLazySingleton<ShiftsConnectorRepository>(
ShiftsConnectorRepositoryImpl.new,
);
i.addLazySingleton<HubsConnectorRepository>(
HubsConnectorRepositoryImpl.new,
);
i.addLazySingleton<BillingConnectorRepository>(
BillingConnectorRepositoryImpl.new,
);
i.addLazySingleton<CoverageConnectorRepository>(
CoverageConnectorRepositoryImpl.new,
);
}
}

View File

@@ -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<String> 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;
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;
}
}
// 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',
);
if (staffId == null || staffId.isEmpty) {
throw Exception('No staff ID found in session.');
}
return staffId;
}
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!;
}
} 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;
}
/// Gets the current business ID from session store or persistent storage.
/// Helper to get the current business ID from the session.
Future<String> 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;
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;
}
}
// 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',
);
if (businessId == null || businessId.isEmpty) {
throw Exception('No business ID found in session.');
}
return businessId;
}
/// Logic to load session data from backend and populate stores.
Future<void> _loadSession(String userId) async {
try {
final fdc.QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
response = await executeProtected(
() => connector.getBusinessesByUserId(userId: user.uid).execute(),
);
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,
),
),
);
}
}
// Load Client Session if applicable
if (role == 'BUSINESS' || role == 'BOTH') {
final response = await connector
.getBusinessesByUserId(userId: userId)
.execute();
if (response.data.businesses.isNotEmpty) {
_cachedBusinessId = response.data.businesses.first.id;
return _cachedBusinessId!;
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) {
throw Exception('Failed to fetch business ID from Data Connect: $e');
}
// 4. Fallback (should ideally not happen if DB is seeded)
return user.uid;
}
/// 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;
}
debugPrint('DataConnectService: Error loading session for $userId: $e');
}
}
if (dt != null) {
return DateTimeUtils.toDeviceTime(dt);
/// 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<T> run<T>(
Future<T> Function() action, {
Future<T> 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<String?> fetchUserRole(String userId) async {
try {
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
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<void> 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();
}
}

View File

@@ -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());
}

View File

@@ -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._();
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -71,7 +71,9 @@ class UiTheme {
),
maximumSize: const Size(double.infinity, 54),
).copyWith(
side: WidgetStateProperty.resolveWith<BorderSide?>((Set<WidgetState> states) {
side: WidgetStateProperty.resolveWith<BorderSide?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.disabled)) {
return const BorderSide(
color: UiColors.borderPrimary,
@@ -80,9 +82,12 @@ class UiTheme {
}
return null;
}),
overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.hovered))
overlayColor: WidgetStateProperty.resolveWith((
Set<WidgetState> 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<WidgetState> states) {
labelTextStyle: WidgetStateProperty.resolveWith((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return UiTypography.footnote2m.textPrimary;
}
@@ -249,13 +256,38 @@ class UiTheme {
// Switch Theme
switchTheme: SwitchThemeData(
trackColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) {
trackColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return UiColors.switchActive;
return UiColors.primary.withAlpha(60);
}
return UiColors.switchInactive;
}),
thumbColor: const WidgetStatePropertyAll<Color>(UiColors.white),
thumbColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return UiColors.primary;
}
return UiColors.white;
}),
trackOutlineColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return UiColors.primary;
}
return UiColors.transparent;
}),
trackOutlineWidth: WidgetStateProperty.resolveWith<double?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.selected)) {
return 1.0;
}
return 0.0;
}),
),
// Checkbox Theme

View File

@@ -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,

View File

@@ -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: <Widget>[
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));
}

View File

@@ -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();

View File

@@ -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:

View File

@@ -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),
);

View File

@@ -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,

View File

@@ -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:

View File

@@ -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';

View File

@@ -2,18 +2,18 @@ import '../../entities/availability/availability_slot.dart';
/// Adapter for [AvailabilitySlot] domain entity.
class AvailabilityAdapter {
static const Map<String, Map<String, String>> _slotDefinitions = {
'MORNING': {
static const Map<String, Map<String, String>> _slotDefinitions = <String, Map<String, String>>{
'MORNING': <String, String>{
'id': 'morning',
'label': 'Morning',
'timeRange': '4:00 AM - 12:00 PM',
},
'AFTERNOON': {
'AFTERNOON': <String, String>{
'id': 'afternoon',
'label': 'Afternoon',
'timeRange': '12:00 PM - 6:00 PM',
},
'EVENING': {
'EVENING': <String, String>{
'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<String, String> def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!;
return AvailabilitySlot(
id: def['id']!,
label: def['label']!,

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