Merge remote-tracking branch 'origin/dev' into codex/feat-architecture-lead-bootstrap
This commit is contained in:
248
.github/workflows/mobile-ci.yml
vendored
Normal file
248
.github/workflows/mobile-ci.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:client_authentication/client_authentication.dart'
|
||||
as client_authentication;
|
||||
import 'package:client_create_order/client_create_order.dart'
|
||||
@@ -10,6 +12,7 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:marionette_flutter/marionette_flutter.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
@@ -20,7 +23,23 @@ import 'firebase_options.dart';
|
||||
import 'src/widgets/session_listener.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final bool isFlutterTest =
|
||||
!kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false;
|
||||
if (kDebugMode && !isFlutterTest) {
|
||||
MarionetteBinding.ensureInitialized(
|
||||
MarionetteConfiguration(
|
||||
isInteractiveWidget: (Type type) =>
|
||||
type == UiButton || type == UiTextField,
|
||||
extractText: (Widget widget) {
|
||||
if (widget is UiTextField) return widget.label;
|
||||
if (widget is UiButton) return widget.text;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
}
|
||||
await Firebase.initializeApp(
|
||||
options: kIsWeb ? DefaultFirebaseOptions.currentPlatform : null,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
42
apps/mobile/apps/client/maestro/README.md
Normal file
42
apps/mobile/apps/client/maestro/README.md
Normal 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 |
|
||||
18
apps/mobile/apps/client/maestro/login.yaml
Normal file
18
apps/mobile/apps/client/maestro/login.yaml
Normal 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"
|
||||
23
apps/mobile/apps/client/maestro/signup.yaml
Normal file
23
apps/mobile/apps/client/maestro/signup.yaml
Normal 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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
url_launcher_windows
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]];
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:core_localization/core_localization.dart' as core_localization;
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krowwithus_staff/firebase_options.dart';
|
||||
import 'package:marionette_flutter/marionette_flutter.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart'
|
||||
as staff_authentication;
|
||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'src/widgets/session_listener.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final bool isFlutterTest = !kIsWeb
|
||||
? Platform.environment.containsKey('FLUTTER_TEST')
|
||||
: false;
|
||||
if (kDebugMode && !isFlutterTest) {
|
||||
MarionetteBinding.ensureInitialized(
|
||||
MarionetteConfiguration(
|
||||
isInteractiveWidget: (Type type) =>
|
||||
type == UiButton || type == UiTextField,
|
||||
extractText: (Widget widget) {
|
||||
if (widget is UiTextField) return widget.label;
|
||||
if (widget is UiButton) return widget.text;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
}
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Register global BLoC observer for centralized error logging
|
||||
@@ -26,7 +47,10 @@ void main() async {
|
||||
|
||||
// Initialize session listener for Firebase Auth state changes
|
||||
DataConnectService.instance.initializeAuthListener(
|
||||
allowedRoles: <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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
41
apps/mobile/apps/staff/maestro/README.md
Normal file
41
apps/mobile/apps/staff/maestro/README.md
Normal 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 |
|
||||
18
apps/mobile/apps/staff/maestro/login.yaml
Normal file
18
apps/mobile/apps/staff/maestro/login.yaml
Normal 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.
|
||||
18
apps/mobile/apps/staff/maestro/signup.yaml
Normal file
18
apps/mobile/apps/staff/maestro/signup.yaml
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal file
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal 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>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ class ClientPaths {
|
||||
/// Generate child route based on the given route and parent route
|
||||
///
|
||||
/// This is useful for creating nested routes within modules.
|
||||
static String childRoute(String parent, String child) {
|
||||
static String childRoute(String parent, String child) {
|
||||
final String childPath = child.replaceFirst(parent, '');
|
||||
|
||||
|
||||
// check if the child path is empty
|
||||
if (childPath.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ensure the child path starts with a '/'
|
||||
if (!childPath.startsWith('/')) {
|
||||
return '/$childPath';
|
||||
@@ -81,6 +81,17 @@ class ClientPaths {
|
||||
/// Access billing history, payment methods, and invoices.
|
||||
static const String billing = '/client-main/billing';
|
||||
|
||||
/// Completion review page - review shift completion records.
|
||||
static const String completionReview =
|
||||
'/client-main/billing/completion-review';
|
||||
|
||||
/// Full list of invoices awaiting approval.
|
||||
static const String awaitingApproval =
|
||||
'/client-main/billing/awaiting-approval';
|
||||
|
||||
/// Invoice ready page - view status of approved invoices.
|
||||
static const String invoiceReady = '/client-main/billing/invoice-ready';
|
||||
|
||||
/// Orders tab - view and manage shift orders.
|
||||
///
|
||||
/// List of all orders with filtering and status tracking.
|
||||
@@ -109,6 +120,12 @@ class ClientPaths {
|
||||
/// View and manage physical locations/hubs where staff are deployed.
|
||||
static const String hubs = '/client-hubs';
|
||||
|
||||
/// Specific hub details.
|
||||
static const String hubDetails = '/client-hubs/details';
|
||||
|
||||
/// Page for adding or editing a hub.
|
||||
static const String editHub = '/client-hubs/edit';
|
||||
|
||||
// ==========================================================================
|
||||
// ORDER CREATION & MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
/// final homePath = ClientPaths.home;
|
||||
/// final shiftsPath = StaffPaths.shifts;
|
||||
/// ```
|
||||
library;
|
||||
|
||||
export 'client/route_paths.dart';
|
||||
export 'client/navigator.dart';
|
||||
|
||||
@@ -29,7 +29,7 @@ extension StaffNavigator on IModularNavigator {
|
||||
// ==========================================================================
|
||||
|
||||
/// Navigates to the root get started/authentication screen.
|
||||
///
|
||||
///
|
||||
/// This effectively logs out the user by navigating to root.
|
||||
/// Used when signing out or session expires.
|
||||
void toInitialPage() {
|
||||
@@ -37,7 +37,7 @@ extension StaffNavigator on IModularNavigator {
|
||||
}
|
||||
|
||||
/// Navigates to the get started page.
|
||||
///
|
||||
///
|
||||
/// This is the landing page for unauthenticated users, offering login/signup options.
|
||||
void toGetStartedPage() {
|
||||
navigate(StaffPaths.getStarted);
|
||||
@@ -64,7 +64,7 @@ extension StaffNavigator on IModularNavigator {
|
||||
/// This is typically called after successful phone verification for new
|
||||
/// staff members. Uses pushReplacement to prevent going back to verification.
|
||||
void toProfileSetup() {
|
||||
pushReplacementNamed(StaffPaths.profileSetup);
|
||||
pushNamed(StaffPaths.profileSetup);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -76,7 +76,12 @@ extension StaffNavigator on IModularNavigator {
|
||||
/// This is the main landing page for authenticated staff members.
|
||||
/// Displays shift cards, quick actions, and notifications.
|
||||
void toStaffHome() {
|
||||
pushNamed(StaffPaths.home);
|
||||
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
|
||||
}
|
||||
|
||||
/// Navigates to the benefits overview page.
|
||||
void toBenefits() {
|
||||
pushNamed(StaffPaths.benefits);
|
||||
}
|
||||
|
||||
/// Navigates to the staff main shell.
|
||||
@@ -84,7 +89,7 @@ extension StaffNavigator on IModularNavigator {
|
||||
/// This is the container with bottom navigation. Navigates to home tab
|
||||
/// by default. Usually you'd navigate to a specific tab instead.
|
||||
void toStaffMain() {
|
||||
navigate('${StaffPaths.main}/home/');
|
||||
pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -98,7 +103,11 @@ extension StaffNavigator on IModularNavigator {
|
||||
/// Parameters:
|
||||
/// * [selectedDate] - Optional date to pre-select in the shifts view
|
||||
/// * [initialTab] - Optional initial tab (via query parameter)
|
||||
void toShifts({DateTime? selectedDate, String? initialTab}) {
|
||||
void toShifts({
|
||||
DateTime? selectedDate,
|
||||
String? initialTab,
|
||||
bool? refreshAvailable,
|
||||
}) {
|
||||
final Map<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.
|
||||
|
||||
@@ -16,14 +16,14 @@ class StaffPaths {
|
||||
/// Generate child route based on the given route and parent route
|
||||
///
|
||||
/// This is useful for creating nested routes within modules.
|
||||
static String childRoute(String parent, String child) {
|
||||
static String childRoute(String parent, String child) {
|
||||
final String childPath = child.replaceFirst(parent, '');
|
||||
|
||||
|
||||
// check if the child path is empty
|
||||
if (childPath.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ensure the child path starts with a '/'
|
||||
if (!childPath.startsWith('/')) {
|
||||
return '/$childPath';
|
||||
@@ -31,7 +31,7 @@ class StaffPaths {
|
||||
|
||||
return childPath;
|
||||
}
|
||||
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATION
|
||||
// ==========================================================================
|
||||
@@ -72,6 +72,9 @@ class StaffPaths {
|
||||
/// Displays shift cards, quick actions, and notifications.
|
||||
static const String home = '/worker-main/home/';
|
||||
|
||||
/// Benefits overview page.
|
||||
static const String benefits = '/worker-main/home/benefits';
|
||||
|
||||
/// Shifts tab - view and manage shifts.
|
||||
///
|
||||
/// Browse available shifts, accepted shifts, and shift history.
|
||||
@@ -107,8 +110,7 @@ class StaffPaths {
|
||||
/// Path format: `/worker-main/shift-details/{shiftId}`
|
||||
///
|
||||
/// Example: `/worker-main/shift-details/shift123`
|
||||
static String shiftDetails(String shiftId) =>
|
||||
'$shiftDetailsRoute/$shiftId';
|
||||
static String shiftDetails(String shiftId) => '$shiftDetailsRoute/$shiftId';
|
||||
|
||||
// ==========================================================================
|
||||
// ONBOARDING & PROFILE SECTIONS
|
||||
@@ -117,8 +119,23 @@ class StaffPaths {
|
||||
/// Personal information onboarding.
|
||||
///
|
||||
/// Collect basic personal information during staff onboarding.
|
||||
static const String onboardingPersonalInfo =
|
||||
'/worker-main/onboarding/personal-info/';
|
||||
static const String onboardingPersonalInfo = '/worker-main/personal-info/';
|
||||
|
||||
// ==========================================================================
|
||||
// PERSONAL INFORMATION & PREFERENCES
|
||||
// ==========================================================================
|
||||
|
||||
/// Language selection page.
|
||||
///
|
||||
/// Allows staff to select their preferred language for the app interface.
|
||||
static const String languageSelection =
|
||||
'/worker-main/personal-info/language-selection/';
|
||||
|
||||
/// Preferred locations editing page.
|
||||
///
|
||||
/// Allows staff to search and select their preferred US work locations.
|
||||
static const String preferredLocations =
|
||||
'/worker-main/personal-info/preferred-locations/';
|
||||
|
||||
/// Emergency contact information.
|
||||
///
|
||||
@@ -135,6 +152,9 @@ class StaffPaths {
|
||||
/// Record sizing and appearance information for uniform allocation.
|
||||
static const String attire = '/worker-main/attire/';
|
||||
|
||||
/// Attire capture page.
|
||||
static const String attireCapture = '/worker-main/attire/capture/';
|
||||
|
||||
// ==========================================================================
|
||||
// COMPLIANCE & DOCUMENTS
|
||||
// ==========================================================================
|
||||
@@ -203,10 +223,33 @@ class StaffPaths {
|
||||
static const String leaderboard = '/leaderboard';
|
||||
|
||||
/// FAQs - frequently asked questions.
|
||||
static const String faqs = '/faqs';
|
||||
///
|
||||
/// Access to frequently asked questions about the staff application.
|
||||
static const String faqs = '/worker-main/faqs/';
|
||||
|
||||
// ==========================================================================
|
||||
// PRIVACY & SECURITY
|
||||
// ==========================================================================
|
||||
|
||||
/// Privacy and security settings.
|
||||
static const String privacy = '/privacy';
|
||||
///
|
||||
/// Manage privacy preferences, location sharing, terms of service,
|
||||
/// and privacy policy.
|
||||
static const String privacySecurity = '/worker-main/privacy-security/';
|
||||
|
||||
/// Terms of Service page.
|
||||
///
|
||||
/// Display the full terms of service document.
|
||||
static const String termsOfService = '/worker-main/privacy-security/terms/';
|
||||
|
||||
/// Privacy Policy page.
|
||||
///
|
||||
/// Display the full privacy policy document.
|
||||
static const String privacyPolicy = '/worker-main/privacy-security/policy/';
|
||||
|
||||
// ==========================================================================
|
||||
// MESSAGING & COMMUNICATION (Placeholders)
|
||||
// ==========================================================================
|
||||
|
||||
/// Messages - internal messaging system (placeholder).
|
||||
static const String messages = '/messages';
|
||||
|
||||
@@ -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>{};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"client_authentication": {
|
||||
"get_started_page": {
|
||||
"title": "Take Control of Your\nShifts and Events",
|
||||
"subtitle": "Streamline your operations with powerful tools to manage schedules, track performance, and keep your team on the same page—all in one place",
|
||||
"subtitle": "Streamline your operations with powerful tools to manage schedules, track performance, and keep your team on the same page\u2014all in one place",
|
||||
"sign_in_button": "Sign In",
|
||||
"create_account_button": "Create Account"
|
||||
},
|
||||
@@ -208,9 +208,25 @@
|
||||
"edit_profile": "Edit Profile",
|
||||
"hubs": "Hubs",
|
||||
"log_out": "Log Out",
|
||||
"log_out_confirmation": "Are you sure you want to log out?",
|
||||
"quick_links": "Quick Links",
|
||||
"clock_in_hubs": "Clock-In Hubs",
|
||||
"billing_payments": "Billing & Payments"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "PREFERENCES",
|
||||
"push": "Push Notifications",
|
||||
"email": "Email Notifications",
|
||||
"sms": "SMS Notifications"
|
||||
},
|
||||
"edit_profile": {
|
||||
"title": "Edit Profile",
|
||||
"first_name": "FIRST NAME",
|
||||
"last_name": "LAST NAME",
|
||||
"email": "EMAIL ADDRESS",
|
||||
"phone": "PHONE NUMBER",
|
||||
"save_button": "Save Changes",
|
||||
"success_message": "Profile updated successfully"
|
||||
}
|
||||
},
|
||||
"client_hubs": {
|
||||
@@ -237,8 +253,40 @@
|
||||
"location_hint": "e.g., Downtown Restaurant",
|
||||
"address_label": "Address",
|
||||
"address_hint": "Full address",
|
||||
"cost_center_label": "Cost Center",
|
||||
"cost_center_hint": "eg: 1001, 1002",
|
||||
"cost_centers_empty": "No cost centers available",
|
||||
"name_required": "Name is required",
|
||||
"address_required": "Address is required",
|
||||
"create_button": "Create Hub"
|
||||
},
|
||||
"edit_hub": {
|
||||
"title": "Edit Hub",
|
||||
"subtitle": "Update hub details",
|
||||
"name_label": "Hub Name *",
|
||||
"name_hint": "e.g., Main Kitchen, Front Desk",
|
||||
"address_label": "Address",
|
||||
"address_hint": "Full address",
|
||||
"cost_center_label": "Cost Center",
|
||||
"cost_center_hint": "eg: 1001, 1002",
|
||||
"cost_centers_empty": "No cost centers available",
|
||||
"name_required": "Name is required",
|
||||
"save_button": "Save Changes",
|
||||
"success": "Hub updated successfully!",
|
||||
"created_success": "Hub created successfully",
|
||||
"updated_success": "Hub updated successfully"
|
||||
},
|
||||
"hub_details": {
|
||||
"title": "Hub Details",
|
||||
"name_label": "Name",
|
||||
"address_label": "Address",
|
||||
"nfc_label": "NFC Tag",
|
||||
"nfc_not_assigned": "Not Assigned",
|
||||
"cost_center_label": "Cost Center",
|
||||
"cost_center_none": "Not Assigned",
|
||||
"edit_button": "Edit Hub",
|
||||
"deleted_success": "Hub deleted successfully"
|
||||
},
|
||||
"nfc_dialog": {
|
||||
"title": "Identify NFC Tag",
|
||||
"instruction": "Tap your phone to the NFC tag to identify it",
|
||||
@@ -293,6 +341,11 @@
|
||||
"date_hint": "Select date",
|
||||
"location_label": "Location",
|
||||
"location_hint": "Enter address",
|
||||
"hub_manager_label": "Shift Contact",
|
||||
"hub_manager_desc": "On-site manager or supervisor for this shift",
|
||||
"hub_manager_hint": "Select Contact",
|
||||
"hub_manager_empty": "No hub managers available",
|
||||
"hub_manager_none": "None",
|
||||
"positions_title": "Positions",
|
||||
"add_position": "Add Position",
|
||||
"position_number": "Position $number",
|
||||
@@ -344,6 +397,41 @@
|
||||
"active": "Active",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"order_edit_sheet": {
|
||||
"title": "Edit Your Order",
|
||||
"vendor_section": "VENDOR",
|
||||
"location_section": "LOCATION",
|
||||
"shift_contact_section": "SHIFT CONTACT",
|
||||
"shift_contact_desc": "On-site manager or supervisor for this shift",
|
||||
"select_contact": "Select Contact",
|
||||
"no_hub_managers": "No hub managers available",
|
||||
"none": "None",
|
||||
"positions_section": "POSITIONS",
|
||||
"add_position": "Add Position",
|
||||
"review_positions": "Review $count Positions",
|
||||
"order_name_hint": "Order name",
|
||||
"remove": "Remove",
|
||||
"select_role_hint": "Select role",
|
||||
"start_label": "Start",
|
||||
"end_label": "End",
|
||||
"workers_label": "Workers",
|
||||
"different_location": "Use different location for this position",
|
||||
"different_location_title": "Different Location",
|
||||
"enter_address_hint": "Enter different address",
|
||||
"no_break": "No Break",
|
||||
"positions": "Positions",
|
||||
"workers": "Workers",
|
||||
"est_cost": "Est. Cost",
|
||||
"positions_breakdown": "Positions Breakdown",
|
||||
"edit_button": "Edit",
|
||||
"confirm_save": "Confirm & Save",
|
||||
"position_singular": "Position",
|
||||
"order_updated_title": "Order Updated!",
|
||||
"order_updated_message": "Your shift has been updated successfully.",
|
||||
"back_to_orders": "Back to Orders",
|
||||
"one_time_order_title": "One-Time Order",
|
||||
"refine_subtitle": "Refine your staffing needs"
|
||||
},
|
||||
"card": {
|
||||
"open": "OPEN",
|
||||
"filled": "FILLED",
|
||||
@@ -389,14 +477,59 @@
|
||||
"month": "Month",
|
||||
"total": "Total",
|
||||
"hours": "$count hours",
|
||||
"export_button": "Export All Invoices",
|
||||
"rate_optimization_title": "Rate Optimization",
|
||||
"rate_optimization_body": "Save $amount/month by switching 3 shifts",
|
||||
"rate_optimization_save": "Save ",
|
||||
"rate_optimization_amount": "$amount/month",
|
||||
"rate_optimization_shifts": " by switching 3 shifts",
|
||||
"view_details": "View Details",
|
||||
"no_invoices_period": "No Invoices for the selected period",
|
||||
"invoices_ready_title": "Invoices Ready",
|
||||
"invoices_ready_subtitle": "You have approved items ready for payment.",
|
||||
"retry": "Retry",
|
||||
"error_occurred": "An error occurred",
|
||||
"invoice_history": "Invoice History",
|
||||
"view_all": "View all",
|
||||
"export_button": "Export All Invoices",
|
||||
"approved_success": "Invoice approved and payment initiated",
|
||||
"flagged_success": "Invoice flagged for review",
|
||||
"pending_badge": "PENDING APPROVAL",
|
||||
"paid_badge": "PAID"
|
||||
"paid_badge": "PAID",
|
||||
"all_caught_up": "All caught up!",
|
||||
"no_pending_invoices": "No invoices awaiting approval",
|
||||
"review_and_approve": "Review & Approve",
|
||||
"review_and_approve_subtitle": "Review and approve for payment",
|
||||
"invoice_ready": "Invoice Ready",
|
||||
"total_amount_label": "Total Amount",
|
||||
"hours_suffix": "hours",
|
||||
"avg_rate_suffix": "/hr avg",
|
||||
"stats": {
|
||||
"total": "Total",
|
||||
"workers": "workers",
|
||||
"hrs": "HRS"
|
||||
},
|
||||
"workers_tab": {
|
||||
"title": "Workers ($count)",
|
||||
"search_hint": "Search workers...",
|
||||
"needs_review": "Needs Review ($count)",
|
||||
"all": "All ($count)",
|
||||
"min_break": "min break"
|
||||
},
|
||||
"actions": {
|
||||
"approve_pay": "Approve & Process Payment",
|
||||
"flag_review": "Flag for Review",
|
||||
"download_pdf": "Download Invoice PDF"
|
||||
},
|
||||
"flag_dialog": {
|
||||
"title": "Flag for Review",
|
||||
"hint": "Describe the issue...",
|
||||
"button": "Flag"
|
||||
},
|
||||
"timesheets": {
|
||||
"title": "Timesheets",
|
||||
"approve_button": "Approve",
|
||||
"decline_button": "Decline",
|
||||
"approved_message": "Timesheet approved"
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
"main": {
|
||||
@@ -434,7 +567,7 @@
|
||||
},
|
||||
"empty_states": {
|
||||
"no_shifts_today": "No shifts scheduled for today",
|
||||
"find_shifts_cta": "Find shifts →",
|
||||
"find_shifts_cta": "Find shifts \u2192",
|
||||
"no_shifts_tomorrow": "No shifts for tomorrow",
|
||||
"no_recommended_shifts": "No recommended shifts"
|
||||
},
|
||||
@@ -444,7 +577,7 @@
|
||||
"amount": "$amount"
|
||||
},
|
||||
"recommended_card": {
|
||||
"act_now": "• ACT NOW",
|
||||
"act_now": "\u2022 ACT NOW",
|
||||
"one_day": "One Day",
|
||||
"today": "Today",
|
||||
"applied_for": "Applied for $title",
|
||||
@@ -458,6 +591,21 @@
|
||||
"sick_days": "Sick Days",
|
||||
"vacation": "Vacation",
|
||||
"holidays": "Holidays"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Your Benefits Overview",
|
||||
"subtitle": "Manage and track your earned benefits here",
|
||||
"request_payment": "Request Payment for $benefit",
|
||||
"request_submitted": "Request submitted for $benefit",
|
||||
"sick_leave_subtitle": "You need at least 8 hours to request sick leave",
|
||||
"vacation_subtitle": "You need 40 hours to claim vacation pay",
|
||||
"holidays_subtitle": "Pay holidays: Thanksgiving, Christmas, New Year",
|
||||
"sick_leave_history": "SICK LEAVE HISTORY",
|
||||
"compliance_banner": "Listed certificates are mandatory for employees. If the employee does not have the complete certificates, they can't proceed with their registration.",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"submitted": "Submitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_match": {
|
||||
@@ -521,7 +669,8 @@
|
||||
"compliance": "COMPLIANCE",
|
||||
"level_up": "LEVEL UP",
|
||||
"finance": "FINANCE",
|
||||
"support": "SUPPORT"
|
||||
"support": "SUPPORT",
|
||||
"settings": "SETTINGS"
|
||||
},
|
||||
"menu_items": {
|
||||
"personal_info": "Personal Info",
|
||||
@@ -543,7 +692,8 @@
|
||||
"timecard": "Timecard",
|
||||
"faqs": "FAQs",
|
||||
"privacy_security": "Privacy & Security",
|
||||
"messages": "Messages"
|
||||
"messages": "Messages",
|
||||
"language": "Language"
|
||||
},
|
||||
"bank_account_page": {
|
||||
"title": "Bank Account",
|
||||
@@ -585,8 +735,21 @@
|
||||
"languages_hint": "English, Spanish, French...",
|
||||
"locations_label": "Preferred Locations",
|
||||
"locations_hint": "Downtown, Midtown, Brooklyn...",
|
||||
"locations_summary_none": "Not set",
|
||||
"save_button": "Save Changes",
|
||||
"save_success": "Personal info saved successfully"
|
||||
"save_success": "Personal info saved successfully",
|
||||
"preferred_locations": {
|
||||
"title": "Preferred Locations",
|
||||
"description": "Choose up to 5 locations in the US where you prefer to work. We'll prioritize shifts near these areas.",
|
||||
"search_hint": "Search a city or area...",
|
||||
"added_label": "YOUR LOCATIONS",
|
||||
"max_reached": "You've reached the maximum of 5 locations",
|
||||
"min_hint": "Add at least 1 preferred location",
|
||||
"save_button": "Save Locations",
|
||||
"save_success": "Preferred locations saved",
|
||||
"remove_tooltip": "Remove location",
|
||||
"empty_state": "No locations added yet.\nSearch above to add your preferred work areas."
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"title": "Experience & Skills",
|
||||
@@ -639,6 +802,12 @@
|
||||
"accept_shift_cta": "Accept a shift to clock in",
|
||||
"soon": "soon",
|
||||
"checked_in_at_label": "Checked in at",
|
||||
"not_in_range": "You must be within $distance m to clock in.",
|
||||
"location_verifying": "Verifying location...",
|
||||
"attire_photo_label": "Attire Photo",
|
||||
"take_attire_photo": "Take Photo",
|
||||
"attire_photo_desc": "Take a photo of your attire for verification.",
|
||||
"attire_captured": "Attire photo captured!",
|
||||
"nfc_dialog": {
|
||||
"scan_title": "NFC Scan Required",
|
||||
"scanned_title": "NFC Scanned",
|
||||
@@ -662,7 +831,7 @@
|
||||
"eta_label": "$min min",
|
||||
"locked_desc": "Most app features are locked while commute mode is on. You'll be able to clock in once you arrive.",
|
||||
"turn_off": "Turn Off Commute Mode",
|
||||
"arrived_title": "You've Arrived! 🎉",
|
||||
"arrived_title": "You've Arrived! \ud83c\udf89",
|
||||
"arrived_desc": "You're at the shift location. Ready to clock in?"
|
||||
},
|
||||
"swipe": {
|
||||
@@ -925,7 +1094,7 @@
|
||||
}
|
||||
},
|
||||
"staff_profile_attire": {
|
||||
"title": "Attire",
|
||||
"title": "Verify Attire",
|
||||
"info_card": {
|
||||
"title": "Your Wardrobe",
|
||||
"description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe."
|
||||
@@ -934,16 +1103,16 @@
|
||||
"required": "REQUIRED",
|
||||
"add_photo": "Add Photo",
|
||||
"added": "Added",
|
||||
"pending": "⏳ Pending verification"
|
||||
"pending": "\u23f3 Pending verification"
|
||||
},
|
||||
"attestation": "I certify that I own these items and will wear them to my shifts. I understand that items are pending manager verification at my first shift.",
|
||||
"actions": {
|
||||
"save": "Save Attire"
|
||||
},
|
||||
"validation": {
|
||||
"select_required": "✓ Select all required items",
|
||||
"upload_required": "✓ Upload photos of required items",
|
||||
"accept_attestation": "✓ Accept attestation"
|
||||
"select_required": "\u2713 Select all required items",
|
||||
"upload_required": "\u2713 Upload photos of required items",
|
||||
"accept_attestation": "\u2713 Accept attestation"
|
||||
}
|
||||
},
|
||||
"staff_shifts": {
|
||||
@@ -1062,8 +1231,23 @@
|
||||
},
|
||||
"card": {
|
||||
"cancelled": "CANCELLED",
|
||||
"compensation": "• 4hr compensation"
|
||||
"compensation": "\u2022 4hr compensation"
|
||||
}
|
||||
},
|
||||
"find_shifts": {
|
||||
"search_hint": "Search jobs, location...",
|
||||
"filter_all": "All Jobs",
|
||||
"filter_one_day": "One Day",
|
||||
"filter_multi_day": "Multi-Day",
|
||||
"filter_long_term": "Long Term",
|
||||
"no_jobs_title": "No jobs available",
|
||||
"no_jobs_subtitle": "Check back later",
|
||||
"application_submitted": "Shift application submitted!",
|
||||
"radius_filter_title": "Radius Filter",
|
||||
"unlimited_distance": "Unlimited distance",
|
||||
"within_miles": "Within $miles miles",
|
||||
"clear": "Clear",
|
||||
"apply": "Apply"
|
||||
}
|
||||
},
|
||||
"staff_time_card": {
|
||||
@@ -1125,9 +1309,34 @@
|
||||
"service_unavailable": "Service is currently unavailable."
|
||||
}
|
||||
},
|
||||
"staff_privacy_security": {
|
||||
"title": "Privacy & Security",
|
||||
"privacy_section": "Privacy",
|
||||
"legal_section": "Legal",
|
||||
"profile_visibility": {
|
||||
"title": "Profile Visibility",
|
||||
"subtitle": "Let clients see your profile"
|
||||
},
|
||||
"terms_of_service": {
|
||||
"title": "Terms of Service"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"title": "Privacy Policy"
|
||||
},
|
||||
"success": {
|
||||
"profile_visibility_updated": "Profile visibility updated successfully!"
|
||||
}
|
||||
},
|
||||
"staff_faqs": {
|
||||
"title": "FAQs",
|
||||
"search_placeholder": "Search questions...",
|
||||
"no_results": "No matching questions found",
|
||||
"contact_support": "Contact Support"
|
||||
},
|
||||
"success": {
|
||||
"hub": {
|
||||
"created": "Hub created successfully!",
|
||||
"updated": "Hub updated successfully!",
|
||||
"deleted": "Hub deleted successfully!",
|
||||
"nfc_assigned": "NFC tag assigned successfully!"
|
||||
},
|
||||
@@ -1140,5 +1349,245 @@
|
||||
"availability": {
|
||||
"updated": "Availability updated successfully"
|
||||
}
|
||||
},
|
||||
"client_reports": {
|
||||
"title": "Workforce Control Tower",
|
||||
"tabs": {
|
||||
"today": "Today",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"quarter": "Quarter"
|
||||
},
|
||||
"metrics": {
|
||||
"total_hrs": {
|
||||
"label": "Total Hrs",
|
||||
"badge": "This period"
|
||||
},
|
||||
"ot_hours": {
|
||||
"label": "OT Hours",
|
||||
"badge": "5.1% of total"
|
||||
},
|
||||
"total_spend": {
|
||||
"label": "Total Spend",
|
||||
"badge": "\u2193 8% vs last week"
|
||||
},
|
||||
"fill_rate": {
|
||||
"label": "Fill Rate",
|
||||
"badge": "\u2191 2% improvement"
|
||||
},
|
||||
"avg_fill_time": {
|
||||
"label": "Avg Fill Time",
|
||||
"badge": "Industry best"
|
||||
},
|
||||
"no_show_rate": {
|
||||
"label": "No-Show Rate",
|
||||
"badge": "Below avg"
|
||||
}
|
||||
},
|
||||
"quick_reports": {
|
||||
"title": "Quick Reports",
|
||||
"export_all": "Export All",
|
||||
"two_click_export": "2-click export",
|
||||
"cards": {
|
||||
"daily_ops": "Daily Ops Report",
|
||||
"spend": "Spend Report",
|
||||
"coverage": "Coverage Report",
|
||||
"no_show": "No-Show Report",
|
||||
"forecast": "Forecast Report",
|
||||
"performance": "Performance Report"
|
||||
}
|
||||
},
|
||||
"daily_ops_report": {
|
||||
"title": "Daily Ops Report",
|
||||
"subtitle": "Real-time shift tracking",
|
||||
"metrics": {
|
||||
"scheduled": {
|
||||
"label": "Scheduled",
|
||||
"sub_value": "shifts"
|
||||
},
|
||||
"workers": {
|
||||
"label": "Workers",
|
||||
"sub_value": "confirmed"
|
||||
},
|
||||
"in_progress": {
|
||||
"label": "In Progress",
|
||||
"sub_value": "active now"
|
||||
},
|
||||
"completed": {
|
||||
"label": "Completed",
|
||||
"sub_value": "done today"
|
||||
}
|
||||
},
|
||||
"all_shifts_title": "ALL SHIFTS",
|
||||
"no_shifts_today": "No shifts scheduled for today",
|
||||
"shift_item": {
|
||||
"time": "Time",
|
||||
"workers": "Workers",
|
||||
"rate": "Rate"
|
||||
},
|
||||
"statuses": {
|
||||
"processing": "Processing",
|
||||
"filling": "Filling",
|
||||
"confirmed": "Confirmed",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Daily Operations Report (Placeholder)"
|
||||
}
|
||||
},
|
||||
"spend_report": {
|
||||
"title": "Spend Report",
|
||||
"subtitle": "Cost analysis & breakdown",
|
||||
"summary": {
|
||||
"total_spend": "Total Spend",
|
||||
"avg_daily": "Avg Daily",
|
||||
"this_week": "This week",
|
||||
"per_day": "Per day"
|
||||
},
|
||||
"chart_title": "Daily Spend Trend",
|
||||
"charts": {
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"spend_by_industry": "Spend by Industry",
|
||||
"no_industry_data": "No industry data available",
|
||||
"industries": {
|
||||
"hospitality": "Hospitality",
|
||||
"events": "Events",
|
||||
"retail": "Retail"
|
||||
},
|
||||
"percent_total": "$percent% of total",
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Spend Report (Placeholder)"
|
||||
}
|
||||
},
|
||||
"forecast_report": {
|
||||
"title": "Forecast Report",
|
||||
"subtitle": "Next 4 weeks projection",
|
||||
"metrics": {
|
||||
"four_week_forecast": "4-Week Forecast",
|
||||
"avg_weekly": "Avg Weekly",
|
||||
"total_shifts": "Total Shifts",
|
||||
"total_hours": "Total Hours"
|
||||
},
|
||||
"badges": {
|
||||
"total_projected": "Total projected",
|
||||
"per_week": "Per week",
|
||||
"scheduled": "Scheduled",
|
||||
"worker_hours": "Worker hours"
|
||||
},
|
||||
"chart_title": "Spending Forecast",
|
||||
"weekly_breakdown": {
|
||||
"title": "WEEKLY BREAKDOWN",
|
||||
"week": "Week $index",
|
||||
"shifts": "Shifts",
|
||||
"hours": "Hours",
|
||||
"avg_shift": "Avg/Shift"
|
||||
},
|
||||
"buttons": {
|
||||
"export": "Export"
|
||||
},
|
||||
"empty_state": "No projections available",
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Forecast Report (Placeholder)"
|
||||
}
|
||||
},
|
||||
"performance_report": {
|
||||
"title": "Performance Report",
|
||||
"subtitle": "Key metrics & benchmarks",
|
||||
"overall_score": {
|
||||
"title": "Overall Performance Score",
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"needs_work": "Needs Work"
|
||||
},
|
||||
"kpis_title": "KEY PERFORMANCE INDICATORS",
|
||||
"kpis": {
|
||||
"fill_rate": "Fill Rate",
|
||||
"completion_rate": "Completion Rate",
|
||||
"on_time_rate": "On-Time Rate",
|
||||
"avg_fill_time": "Avg Fill Time",
|
||||
"target_prefix": "Target: ",
|
||||
"target_hours": "$hours hrs",
|
||||
"target_percent": "$percent%",
|
||||
"met": "\u2713 Met",
|
||||
"close": "\u2192 Close",
|
||||
"miss": "\u2717 Miss"
|
||||
},
|
||||
"additional_metrics_title": "ADDITIONAL METRICS",
|
||||
"additional_metrics": {
|
||||
"total_shifts": "Total Shifts",
|
||||
"no_show_rate": "No-Show Rate",
|
||||
"worker_pool": "Worker Pool",
|
||||
"avg_rating": "Avg Rating"
|
||||
},
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Performance Report (Placeholder)"
|
||||
}
|
||||
},
|
||||
"no_show_report": {
|
||||
"title": "No-Show Report",
|
||||
"subtitle": "Reliability tracking",
|
||||
"metrics": {
|
||||
"no_shows": "No-Shows",
|
||||
"rate": "Rate",
|
||||
"workers": "Workers"
|
||||
},
|
||||
"workers_list_title": "WORKERS WITH NO-SHOWS",
|
||||
"no_show_count": "$count no-show(s)",
|
||||
"latest_incident": "Latest incident",
|
||||
"risks": {
|
||||
"high": "High Risk",
|
||||
"medium": "Medium Risk",
|
||||
"low": "Low Risk"
|
||||
},
|
||||
"empty_state": "No workers flagged for no-shows",
|
||||
"placeholders": {
|
||||
"export_message": "Exporting No-Show Report (Placeholder)"
|
||||
}
|
||||
},
|
||||
"coverage_report": {
|
||||
"title": "Coverage Report",
|
||||
"subtitle": "Staffing levels & gaps",
|
||||
"metrics": {
|
||||
"avg_coverage": "Avg Coverage",
|
||||
"full": "Full",
|
||||
"needs_help": "Needs Help"
|
||||
},
|
||||
"next_7_days": "NEXT 7 DAYS",
|
||||
"empty_state": "No shifts scheduled",
|
||||
"shift_item": {
|
||||
"confirmed_workers": "$confirmed/$needed workers confirmed",
|
||||
"spots_remaining": "$count spots remaining",
|
||||
"one_spot_remaining": "1 spot remaining",
|
||||
"fully_staffed": "Fully staffed"
|
||||
},
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Coverage Report (Placeholder)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"client_coverage": {
|
||||
"worker_row": {
|
||||
"verify": "Verify",
|
||||
"verified_message": "Worker attire verified for $name"
|
||||
}
|
||||
},
|
||||
"staff_payments": {
|
||||
"early_pay": {
|
||||
"title": "Early Pay",
|
||||
"available_label": "Available for Cash Out",
|
||||
"select_amount": "Select Amount",
|
||||
"hint_amount": "Enter amount to cash out",
|
||||
"deposit_to": "Instant deposit to:",
|
||||
"confirm_button": "Confirm Cash Out",
|
||||
"success_message": "Cash out request submitted!",
|
||||
"fee_notice": "A small fee of \\$1.99 may apply for instant transfers."
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedStaffId != null) return _cachedStaffId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetStaffByUserIdData,
|
||||
dc.GetStaffByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getStaffByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
_cachedStaffId = response.data.staffs.first.id;
|
||||
return _cachedStaffId!;
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
staffId = dc.StaffSessionStore.instance.session?.staff?.id;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch staff ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
throw Exception('No staff ID found in session.');
|
||||
}
|
||||
return staffId;
|
||||
}
|
||||
|
||||
/// Gets the current business ID from session store or persistent storage.
|
||||
/// Helper to get the current business ID from the session.
|
||||
Future<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;
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedBusinessId != null) return _cachedBusinessId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getBusinessesByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
_cachedBusinessId = response.data.businesses.first.id;
|
||||
return _cachedBusinessId!;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch business ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
throw Exception('No business ID found in session.');
|
||||
}
|
||||
return businessId;
|
||||
}
|
||||
|
||||
/// Converts a Data Connect timestamp/string/json to a [DateTime].
|
||||
DateTime? toDateTime(dynamic t) {
|
||||
if (t == null) return null;
|
||||
DateTime? dt;
|
||||
if (t is fdc.Timestamp) {
|
||||
dt = t.toDateTime();
|
||||
} else if (t is String) {
|
||||
dt = DateTime.tryParse(t);
|
||||
} else {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toJson() as String);
|
||||
} catch (_) {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toString());
|
||||
} catch (e) {
|
||||
dt = null;
|
||||
/// Logic to load session data from backend and populate stores.
|
||||
Future<void> _loadSession(String userId) async {
|
||||
try {
|
||||
final role = await fetchUserRole(userId);
|
||||
if (role == null) return;
|
||||
|
||||
// Load Staff Session if applicable
|
||||
if (role == 'STAFF' || role == 'BOTH') {
|
||||
final response = await connector
|
||||
.getStaffByUserId(userId: userId)
|
||||
.execute();
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
final s = response.data.staffs.first;
|
||||
dc.StaffSessionStore.instance.setSession(
|
||||
dc.StaffSession(
|
||||
ownerId: s.ownerId,
|
||||
staff: domain.Staff(
|
||||
id: s.id,
|
||||
authProviderId: s.userId,
|
||||
name: s.fullName,
|
||||
email: s.email ?? '',
|
||||
phone: s.phone,
|
||||
status: domain.StaffStatus.completedProfile,
|
||||
address: s.addres,
|
||||
avatar: s.photoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dt != null) {
|
||||
return DateTimeUtils.toDeviceTime(dt);
|
||||
// Load Client Session if applicable
|
||||
if (role == 'BUSINESS' || role == 'BOTH') {
|
||||
final response = await connector
|
||||
.getBusinessesByUserId(userId: userId)
|
||||
.execute();
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
final b = response.data.businesses.first;
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: b.id,
|
||||
businessName: b.businessName,
|
||||
email: b.email,
|
||||
city: b.city,
|
||||
contactName: b.contactName,
|
||||
companyLogoUrl: b.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DataConnectService: Error loading session for $userId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a Data Connect [Timestamp] to a Dart [DateTime] in local time.
|
||||
///
|
||||
/// Firebase Data Connect always stores and returns timestamps in UTC.
|
||||
/// Calling [toLocal] ensures the result reflects the device's timezone so
|
||||
/// that shift dates, start/end times, and formatted strings are correct for
|
||||
/// the end user.
|
||||
DateTime? toDateTime(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
if (timestamp is fdc.Timestamp) {
|
||||
return timestamp.toDateTime().toLocal();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a [DateTime] to a Firebase Data Connect [Timestamp].
|
||||
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
|
||||
///
|
||||
/// Converts the [DateTime] to UTC before creating the [Timestamp].
|
||||
fdc.Timestamp toTimestamp(DateTime dateTime) {
|
||||
final DateTime utc = dateTime.toUtc();
|
||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
||||
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
||||
return fdc.Timestamp(nanoseconds, seconds);
|
||||
final int millis = utc.millisecondsSinceEpoch;
|
||||
final int seconds = millis ~/ 1000;
|
||||
final int nanos = (millis % 1000) * 1000000;
|
||||
return fdc.Timestamp(nanos, seconds);
|
||||
}
|
||||
|
||||
// --- 3. Unified Execution ---
|
||||
// Repositories call this to benefit from centralized error handling/logging
|
||||
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
|
||||
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
return toTimestamp(dateTime);
|
||||
}
|
||||
|
||||
/// Executes an operation with centralized error handling.
|
||||
Future<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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._();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user