Merge pull request #439 from Oloodi/408-feature-implement-paidunpaid-breaks---client-app-frontend-development
Few features of the mobile applications are completed
This commit is contained in:
237
.github/workflows/mobile-ci.yml
vendored
Normal file
237
.github/workflows/mobile-ci.yml
vendored
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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 with base branch
|
||||||
|
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||||
|
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||||
|
CHANGED_FILES=$(git diff --name-only origin/$BASE_REF..origin/$HEAD_REF 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.19.x'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 📦 Get Flutter dependencies
|
||||||
|
run: |
|
||||||
|
cd apps/mobile
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
- name: 🔨 Run compilation check
|
||||||
|
run: |
|
||||||
|
cd apps/mobile
|
||||||
|
echo "⚙️ Running build_runner..."
|
||||||
|
flutter pub run build_runner build --delete-conflicting-outputs 2>&1 || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔬 Running flutter analyze on all files..."
|
||||||
|
flutter analyze lib/ --no-fatal-infos 2>&1 | tee analyze_output.txt || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
# Check for actual errors (not just warnings)
|
||||||
|
if grep -E "^\s*(error|SEVERE):" analyze_output.txt > /dev/null; then
|
||||||
|
echo "❌ COMPILATION ERRORS FOUND:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -B 2 -A 1 -E "^\s*(error|SEVERE):" analyze_output.txt | sed 's/^/ /'
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Compilation check PASSED - No errors found"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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.19.x'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 📦 Get Flutter dependencies
|
||||||
|
run: |
|
||||||
|
cd apps/mobile
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
- name: 🔍 Lint changed Dart files
|
||||||
|
run: |
|
||||||
|
cd apps/mobile
|
||||||
|
|
||||||
|
# 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 ]]; then
|
||||||
|
echo "📝 Analyzing: $file"
|
||||||
|
|
||||||
|
if ! flutter analyze "$file" --no-fatal-infos 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
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ class StaffPaths {
|
|||||||
/// Generate child route based on the given route and parent route
|
/// Generate child route based on the given route and parent route
|
||||||
///
|
///
|
||||||
/// This is useful for creating nested routes within modules.
|
/// 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, '');
|
final String childPath = child.replaceFirst(parent, '');
|
||||||
|
|
||||||
// check if the child path is empty
|
// check if the child path is empty
|
||||||
@@ -107,8 +107,7 @@ class StaffPaths {
|
|||||||
/// Path format: `/worker-main/shift-details/{shiftId}`
|
/// Path format: `/worker-main/shift-details/{shiftId}`
|
||||||
///
|
///
|
||||||
/// Example: `/worker-main/shift-details/shift123`
|
/// Example: `/worker-main/shift-details/shift123`
|
||||||
static String shiftDetails(String shiftId) =>
|
static String shiftDetails(String shiftId) => '$shiftDetailsRoute/$shiftId';
|
||||||
'$shiftDetailsRoute/$shiftId';
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// ONBOARDING & PROFILE SECTIONS
|
// ONBOARDING & PROFILE SECTIONS
|
||||||
@@ -117,8 +116,17 @@ class StaffPaths {
|
|||||||
/// Personal information onboarding.
|
/// Personal information onboarding.
|
||||||
///
|
///
|
||||||
/// Collect basic personal information during staff onboarding.
|
/// Collect basic personal information during staff onboarding.
|
||||||
static const String onboardingPersonalInfo =
|
static const String onboardingPersonalInfo = '/worker-main/personal-info/';
|
||||||
'/worker-main/onboarding/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/';
|
||||||
|
|
||||||
/// Emergency contact information.
|
/// Emergency contact information.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -17,3 +17,14 @@ export 'src/services/mixins/session_handler_mixin.dart';
|
|||||||
|
|
||||||
export 'src/session/staff_session_store.dart';
|
export 'src/session/staff_session_store.dart';
|
||||||
export 'src/services/mixins/data_error_handler.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';
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.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({
|
||||||
|
DataConnectService? service,
|
||||||
|
}) : _service = service ?? DataConnectService.instance;
|
||||||
|
|
||||||
|
final DataConnectService _service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> getProfileCompletion() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
final QueryResult<GetStaffProfileCompletionData,
|
||||||
|
GetStaffProfileCompletionVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.getStaffProfileCompletion(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
final GetStaffProfileCompletionStaff? staff = response.data.staff;
|
||||||
|
final List<GetStaffProfileCompletionEmergencyContacts>
|
||||||
|
emergencyContacts = response.data.emergencyContacts;
|
||||||
|
final List<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<GetStaffPersonalInfoCompletionData,
|
||||||
|
GetStaffPersonalInfoCompletionVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.getStaffPersonalInfoCompletion(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
final 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<GetStaffEmergencyProfileCompletionData,
|
||||||
|
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<GetStaffExperienceProfileCompletionData,
|
||||||
|
GetStaffExperienceProfileCompletionVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.getStaffExperienceProfileCompletion(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
final 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<GetStaffTaxFormsProfileCompletionData,
|
||||||
|
GetStaffTaxFormsProfileCompletionVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.getStaffTaxFormsProfileCompletion(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return response.data.taxForms.isNotEmpty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if personal info is complete.
|
||||||
|
bool _isPersonalInfoComplete(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 ?? false) &&
|
||||||
|
(email?.trim().isNotEmpty ?? false) &&
|
||||||
|
(phone?.trim().isNotEmpty ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if staff has experience data (skills or industries).
|
||||||
|
bool _hasExperience(GetStaffExperienceProfileCompletionStaff? staff) {
|
||||||
|
if (staff == null) return false;
|
||||||
|
final dynamic skills = staff.skills;
|
||||||
|
final dynamic industries = staff.industries;
|
||||||
|
return (skills is List && skills.isNotEmpty) ||
|
||||||
|
(industries is List && industries.isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if the profile is complete based on all sections.
|
||||||
|
bool _isProfileComplete(
|
||||||
|
GetStaffProfileCompletionStaff? staff,
|
||||||
|
List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
|
||||||
|
List<GetStaffProfileCompletionTaxForms> taxForms,
|
||||||
|
) {
|
||||||
|
if (staff == null) return false;
|
||||||
|
final dynamic skills = staff.skills;
|
||||||
|
final dynamic industries = staff.industries;
|
||||||
|
final bool hasExperience =
|
||||||
|
(skills is List && skills.isNotEmpty) ||
|
||||||
|
(industries is List && industries.isNotEmpty);
|
||||||
|
return emergencyContacts.isNotEmpty &&
|
||||||
|
taxForms.isNotEmpty &&
|
||||||
|
hasExperience;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> getStaffProfile() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.getStaffById(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (response.data.staff == null) {
|
||||||
|
throw const ServerException(
|
||||||
|
technicalMessage: 'Staff not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final GetStaffByIdStaff rawStaff = response.data.staff!;
|
||||||
|
|
||||||
|
// Map the raw data connect object to the Domain Entity
|
||||||
|
return Staff(
|
||||||
|
id: rawStaff.id,
|
||||||
|
authProviderId: rawStaff.userId,
|
||||||
|
name: rawStaff.fullName,
|
||||||
|
email: rawStaff.email ?? '',
|
||||||
|
phone: rawStaff.phone,
|
||||||
|
avatar: rawStaff.photoUrl,
|
||||||
|
status: StaffStatus.active,
|
||||||
|
address: rawStaff.addres,
|
||||||
|
totalShifts: rawStaff.totalShifts,
|
||||||
|
averageRating: rawStaff.averageRating,
|
||||||
|
onTimeRate: rawStaff.onTimeRate,
|
||||||
|
noShowCount: rawStaff.noShowCount,
|
||||||
|
cancellationCount: rawStaff.cancellationCount,
|
||||||
|
reliabilityScore: rawStaff.reliabilityScore,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await _service.auth.signOut();
|
||||||
|
_service.clearCache();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error signing out: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
/// Signs out the current user.
|
||||||
|
///
|
||||||
|
/// Clears the user's session and authentication state.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the sign-out fails.
|
||||||
|
Future<void> signOut();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
krow_domain:
|
krow_domain:
|
||||||
path: ../domain
|
path: ../domain
|
||||||
|
krow_core:
|
||||||
|
path: ../core
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
firebase_auth: ^6.1.4
|
firebase_auth: ^6.1.4
|
||||||
krow_core: ^0.0.1
|
|
||||||
|
|||||||
@@ -5,17 +5,14 @@ publish_to: none
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.10.0 <4.0.0'
|
sdk: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.0.0"
|
flutter: ">=3.0.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.0
|
|
||||||
flutter_modular: ^6.3.0
|
|
||||||
equatable: ^2.0.5
|
|
||||||
|
|
||||||
# Architecture Packages
|
# Architecture Packages
|
||||||
design_system:
|
design_system:
|
||||||
path: ../../../design_system
|
path: ../../../design_system
|
||||||
core_localization:
|
core_localization:
|
||||||
@@ -30,10 +27,12 @@ dependencies:
|
|||||||
path: ../view_orders
|
path: ../view_orders
|
||||||
billing:
|
billing:
|
||||||
path: ../billing
|
path: ../billing
|
||||||
|
krow_core:
|
||||||
|
path: ../../../core
|
||||||
|
|
||||||
# Intentionally commenting these out as they might not exist yet
|
flutter_bloc: ^8.1.0
|
||||||
# client_settings:
|
flutter_modular: ^6.3.0
|
||||||
# path: ../settings
|
equatable: ^2.0.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
|||||||
throw UnimplementedError('Rapid order IA is not connected yet.');
|
throw UnimplementedError('Rapid order IA is not connected yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reorder(String previousOrderId, DateTime newDate) async {
|
||||||
|
// TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date.
|
||||||
|
throw UnimplementedError('Reorder functionality is not yet implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
double _calculateShiftCost(domain.OneTimeOrder order) {
|
double _calculateShiftCost(domain.OneTimeOrder order) {
|
||||||
double total = 0;
|
double total = 0;
|
||||||
for (final domain.OneTimeOrderPosition position in order.positions) {
|
for (final domain.OneTimeOrderPosition position in order.positions) {
|
||||||
|
|||||||
@@ -27,4 +27,10 @@ abstract interface class ClientCreateOrderRepositoryInterface {
|
|||||||
///
|
///
|
||||||
/// [description] is the text message (or transcribed voice) describing the need.
|
/// [description] is the text message (or transcribed voice) describing the need.
|
||||||
Future<void> createRapidOrder(String description);
|
Future<void> createRapidOrder(String description);
|
||||||
|
|
||||||
|
/// Reorders an existing staffing order with a new date.
|
||||||
|
///
|
||||||
|
/// [previousOrderId] is the ID of the order to reorder.
|
||||||
|
/// [newDate] is the new date for the order.
|
||||||
|
Future<void> reorder(String previousOrderId, DateTime newDate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ReorderArguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Use case for reordering an existing staffing order.
|
/// Use case for reordering an existing staffing order.
|
||||||
class ReorderUseCase implements UseCase<Future<void>, ReorderArguments> {
|
class ReorderUseCase implements UseCase<ReorderArguments, void> {
|
||||||
const ReorderUseCase(this._repository);
|
const ReorderUseCase(this._repository);
|
||||||
|
|
||||||
final ClientCreateOrderRepositoryInterface _repository;
|
final ClientCreateOrderRepositoryInterface _repository;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Modular.to.navigate(
|
Modular.to.navigate(
|
||||||
'/client-main/orders/',
|
ClientPaths.orders,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
'initialDate': initialDate.toIso8601String(),
|
'initialDate': initialDate.toIso8601String(),
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -1,464 +0,0 @@
|
|||||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
|
|
||||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart';
|
|
||||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart';
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class CoverageReportPage extends StatefulWidget {
|
|
||||||
const CoverageReportPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CoverageReportPage> createState() => _CoverageReportPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CoverageReportPageState extends State<CoverageReportPage> {
|
|
||||||
DateTime _startDate = DateTime.now();
|
|
||||||
DateTime _endDate = DateTime.now().add(const Duration(days: 6));
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider(
|
|
||||||
create: (context) => Modular.get<CoverageBloc>()
|
|
||||||
..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)),
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: UiColors.bgMenu,
|
|
||||||
body: BlocBuilder<CoverageBloc, CoverageState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state is CoverageLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state is CoverageError) {
|
|
||||||
return Center(child: Text(state.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state is CoverageLoaded) {
|
|
||||||
final report = state.report;
|
|
||||||
|
|
||||||
// Compute "Full" and "Needs Help" counts from daily coverage
|
|
||||||
final fullDays = report.dailyCoverage
|
|
||||||
.where((d) => d.percentage >= 100)
|
|
||||||
.length;
|
|
||||||
final needsHelpDays = report.dailyCoverage
|
|
||||||
.where((d) => d.percentage < 80)
|
|
||||||
.length;
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// ── Header ───────────────────────────────────────────
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 60,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
bottom: 80, // Increased bottom padding for overlap background
|
|
||||||
),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
UiColors.primary,
|
|
||||||
UiColors.buttonPrimaryHover,
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Title row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white.withOpacity(0.2),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
UiIcons.arrowLeft,
|
|
||||||
color: UiColors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
context.t.client_reports.coverage_report
|
|
||||||
.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: UiColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
context.t.client_reports.coverage_report
|
|
||||||
.subtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color:
|
|
||||||
UiColors.white.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Export button
|
|
||||||
/*
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.t.client_reports.coverage_report
|
|
||||||
.placeholders.export_message,
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(UiIcons.download,
|
|
||||||
size: 14, color: UiColors.primary),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Export',
|
|
||||||
style: TextStyle(
|
|
||||||
color: UiColors.primary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
*/
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── 3 summary stat chips (Moved here for overlap) ──
|
|
||||||
Transform.translate(
|
|
||||||
offset: const Offset(0, -60), // Pull up to overlap header
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_CoverageStatCard(
|
|
||||||
icon: UiIcons.trendingUp,
|
|
||||||
label: context.t.client_reports.coverage_report.metrics.avg_coverage,
|
|
||||||
value: '${report.overallCoverage.toStringAsFixed(0)}%',
|
|
||||||
iconColor: UiColors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_CoverageStatCard(
|
|
||||||
icon: UiIcons.checkCircle,
|
|
||||||
label: context.t.client_reports.coverage_report.metrics.full,
|
|
||||||
value: fullDays.toString(),
|
|
||||||
iconColor: UiColors.success,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_CoverageStatCard(
|
|
||||||
icon: UiIcons.warning,
|
|
||||||
label: context.t.client_reports.coverage_report.metrics.needs_help,
|
|
||||||
value: needsHelpDays.toString(),
|
|
||||||
iconColor: UiColors.error,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────
|
|
||||||
Transform.translate(
|
|
||||||
offset: const Offset(0, -60), // Pull up to overlap header
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Section label
|
|
||||||
Text(
|
|
||||||
context.t.client_reports.coverage_report.next_7_days,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: UiColors.textPrimary,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
if (report.dailyCoverage.isEmpty)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(40),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
context.t.client_reports.coverage_report.empty_state,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
...report.dailyCoverage.map(
|
|
||||||
(day) => _DayCoverageCard(
|
|
||||||
date: DateFormat('EEE, MMM d').format(day.date),
|
|
||||||
filled: day.filled,
|
|
||||||
needed: day.needed,
|
|
||||||
percentage: day.percentage,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 100),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Header stat chip (inside the blue header) ─────────────────────────────────
|
|
||||||
// ── Header stat card (boxes inside the blue header overlap) ───────────────────
|
|
||||||
class _CoverageStatCard extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final Color iconColor;
|
|
||||||
|
|
||||||
const _CoverageStatCard({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.iconColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Expanded(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16), // Increased padding
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16), // More rounded
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.black.withOpacity(0.04),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 14,
|
|
||||||
color: iconColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: UiColors.textSecondary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20, // Slightly smaller to fit if needed
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: UiColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Day coverage card ─────────────────────────────────────────────────────────
|
|
||||||
class _DayCoverageCard extends StatelessWidget {
|
|
||||||
final String date;
|
|
||||||
final int filled;
|
|
||||||
final int needed;
|
|
||||||
final double percentage;
|
|
||||||
|
|
||||||
const _DayCoverageCard({
|
|
||||||
required this.date,
|
|
||||||
required this.filled,
|
|
||||||
required this.needed,
|
|
||||||
required this.percentage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isFullyStaffed = percentage >= 100;
|
|
||||||
final spotsRemaining = (needed - filled).clamp(0, needed);
|
|
||||||
|
|
||||||
final barColor = percentage >= 95
|
|
||||||
? UiColors.success
|
|
||||||
: percentage >= 80
|
|
||||||
? UiColors.primary
|
|
||||||
: UiColors.error;
|
|
||||||
|
|
||||||
final badgeColor = percentage >= 95
|
|
||||||
? UiColors.success
|
|
||||||
: percentage >= 80
|
|
||||||
? UiColors.primary
|
|
||||||
: UiColors.error;
|
|
||||||
|
|
||||||
final badgeBg = percentage >= 95
|
|
||||||
? UiColors.tagSuccess
|
|
||||||
: percentage >= 80
|
|
||||||
? UiColors.primary.withOpacity(0.1) // Blue tint
|
|
||||||
: UiColors.tagError;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.black.withOpacity(0.03),
|
|
||||||
blurRadius: 6,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
date,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
color: UiColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
context.t.client_reports.coverage_report.shift_item.confirmed_workers(confirmed: filled.toString(), needed: needed.toString()),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Percentage badge
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 5,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: badgeBg,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${percentage.toStringAsFixed(0)}%',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: badgeColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: (percentage / 100).clamp(0.0, 1.0),
|
|
||||||
backgroundColor: UiColors.bgSecondary,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(barColor),
|
|
||||||
minHeight: 6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Text(
|
|
||||||
isFullyStaffed
|
|
||||||
? context.t.client_reports.coverage_report.shift_item.fully_staffed
|
|
||||||
: spotsRemaining == 1
|
|
||||||
? context.t.client_reports.coverage_report.shift_item.one_spot_remaining
|
|
||||||
: context.t.client_reports.coverage_report.shift_item.spots_remaining(count: spotsRemaining.toString()),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: isFullyStaffed
|
|
||||||
? UiColors.success
|
|
||||||
: UiColors.textSecondary,
|
|
||||||
fontWeight: isFullyStaffed
|
|
||||||
? FontWeight.w500
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
class ReportsPage extends StatefulWidget {
|
class ReportsPage extends StatefulWidget {
|
||||||
const ReportsPage({super.key});
|
const ReportsPage({super.key});
|
||||||
@@ -36,8 +37,8 @@ class _ReportsPageState extends State<ReportsPage>
|
|||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
DateTime(DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1,
|
DateTime(
|
||||||
1),
|
DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, 1),
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -102,8 +103,7 @@ class _ReportsPageState extends State<ReportsPage>
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () =>
|
onTap: () => Modular.to.toClientHome(),
|
||||||
Modular.to.navigate('/client-main/home'),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -209,8 +209,8 @@ class _ReportsPageState extends State<ReportsPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final summary = (state as ReportsSummaryLoaded).summary;
|
final summary = (state as ReportsSummaryLoaded).summary;
|
||||||
final currencyFmt =
|
final currencyFmt = NumberFormat.currency(
|
||||||
NumberFormat.currency(symbol: '\$', decimalDigits: 0);
|
symbol: '\$', decimalDigits: 0);
|
||||||
|
|
||||||
return GridView.count(
|
return GridView.count(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
@@ -261,8 +261,7 @@ class _ReportsPageState extends State<ReportsPage>
|
|||||||
icon: UiIcons.trendingUp,
|
icon: UiIcons.trendingUp,
|
||||||
label: context
|
label: context
|
||||||
.t.client_reports.metrics.fill_rate.label,
|
.t.client_reports.metrics.fill_rate.label,
|
||||||
value:
|
value: '${summary.fillRate.toStringAsFixed(0)}%',
|
||||||
'${summary.fillRate.toStringAsFixed(0)}%',
|
|
||||||
badgeText: context
|
badgeText: context
|
||||||
.t.client_reports.metrics.fill_rate.badge,
|
.t.client_reports.metrics.fill_rate.badge,
|
||||||
badgeColor: UiColors.tagInProgress,
|
badgeColor: UiColors.tagInProgress,
|
||||||
@@ -271,12 +270,12 @@ class _ReportsPageState extends State<ReportsPage>
|
|||||||
),
|
),
|
||||||
_MetricCard(
|
_MetricCard(
|
||||||
icon: UiIcons.clock,
|
icon: UiIcons.clock,
|
||||||
label: context.t.client_reports.metrics
|
label: context
|
||||||
.avg_fill_time.label,
|
.t.client_reports.metrics.avg_fill_time.label,
|
||||||
value:
|
value:
|
||||||
'${summary.avgFillTimeHours.toStringAsFixed(1)} hrs',
|
'${summary.avgFillTimeHours.toStringAsFixed(1)} hrs',
|
||||||
badgeText: context.t.client_reports.metrics
|
badgeText: context
|
||||||
.avg_fill_time.badge,
|
.t.client_reports.metrics.avg_fill_time.badge,
|
||||||
badgeColor: UiColors.tagInProgress,
|
badgeColor: UiColors.tagInProgress,
|
||||||
badgeTextColor: UiColors.textLink,
|
badgeTextColor: UiColors.textLink,
|
||||||
iconColor: UiColors.iconActive,
|
iconColor: UiColors.iconActive,
|
||||||
@@ -474,8 +473,7 @@ class _MetricCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: badgeColor,
|
color: badgeColor,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
@@ -580,4 +578,3 @@ class _ReportCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart';
|
import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart';
|
||||||
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
|
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
|
||||||
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart';
|
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart';
|
||||||
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
|
|
||||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
|
|
||||||
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
|
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
|
||||||
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
|
|
||||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
|
||||||
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart';
|
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart';
|
||||||
import 'package:client_reports/src/presentation/pages/coverage_report_page.dart';
|
|
||||||
import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart';
|
import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart';
|
||||||
import 'package:client_reports/src/presentation/pages/forecast_report_page.dart';
|
import 'package:client_reports/src/presentation/pages/forecast_report_page.dart';
|
||||||
import 'package:client_reports/src/presentation/pages/no_show_report_page.dart';
|
import 'package:client_reports/src/presentation/pages/no_show_report_page.dart';
|
||||||
@@ -26,7 +24,6 @@ class ReportsModule extends Module {
|
|||||||
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
|
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
|
||||||
i.add<DailyOpsBloc>(DailyOpsBloc.new);
|
i.add<DailyOpsBloc>(DailyOpsBloc.new);
|
||||||
i.add<SpendBloc>(SpendBloc.new);
|
i.add<SpendBloc>(SpendBloc.new);
|
||||||
i.add<CoverageBloc>(CoverageBloc.new);
|
|
||||||
i.add<ForecastBloc>(ForecastBloc.new);
|
i.add<ForecastBloc>(ForecastBloc.new);
|
||||||
i.add<PerformanceBloc>(PerformanceBloc.new);
|
i.add<PerformanceBloc>(PerformanceBloc.new);
|
||||||
i.add<NoShowBloc>(NoShowBloc.new);
|
i.add<NoShowBloc>(NoShowBloc.new);
|
||||||
@@ -41,6 +38,5 @@ class ReportsModule extends Module {
|
|||||||
r.child('/forecast', child: (_) => const ForecastReportPage());
|
r.child('/forecast', child: (_) => const ForecastReportPage());
|
||||||
r.child('/performance', child: (_) => const PerformanceReportPage());
|
r.child('/performance', child: (_) => const PerformanceReportPage());
|
||||||
r.child('/no-show', child: (_) => const NoShowReportPage());
|
r.child('/no-show', child: (_) => const NoShowReportPage());
|
||||||
r.child('/coverage', child: (_) => const CoverageReportPage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
|
||||||
import '../../blocs/client_settings_bloc.dart';
|
import '../../blocs/client_settings_bloc.dart';
|
||||||
|
|
||||||
/// A widget that displays the log out button.
|
/// A widget that displays the log out button.
|
||||||
@@ -59,7 +58,7 @@ class SettingsLogout extends StatelessWidget {
|
|||||||
style: UiTypography.headline3m.textPrimary,
|
style: UiTypography.headline3m.textPrimary,
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
t.client_settings.profile.log_out_confirmation,
|
'Are you sure you want to log out?',
|
||||||
style: UiTypography.body2r.textSecondary,
|
style: UiTypography.body2r.textSecondary,
|
||||||
),
|
),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
import '../../domain/repositories/profile_repository.dart';
|
|
||||||
|
|
||||||
/// Implementation of [ProfileRepositoryInterface] that delegates to data_connect.
|
|
||||||
///
|
|
||||||
/// This implementation follows Clean Architecture by:
|
|
||||||
/// - Implementing the domain layer's repository interface
|
|
||||||
/// - Delegating all data access to the data_connect package
|
|
||||||
/// - Not containing any business logic
|
|
||||||
/// - Only performing data transformation/mapping if needed
|
|
||||||
///
|
|
||||||
/// Currently uses [ProfileRepositoryMock] from data_connect.
|
|
||||||
/// When Firebase Data Connect is ready, this will be swapped with a real implementation.
|
|
||||||
class ProfileRepositoryImpl
|
|
||||||
implements ProfileRepositoryInterface {
|
|
||||||
/// Creates a [ProfileRepositoryImpl].
|
|
||||||
ProfileRepositoryImpl() : _service = DataConnectService.instance;
|
|
||||||
|
|
||||||
final DataConnectService _service;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Staff> getStaffProfile() async {
|
|
||||||
return _service.run(() async {
|
|
||||||
final staffId = await _service.getStaffId();
|
|
||||||
final response = await _service.connector.getStaffById(id: staffId).execute();
|
|
||||||
|
|
||||||
if (response.data.staff == null) {
|
|
||||||
throw const ServerException(technicalMessage: 'Staff not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
final GetStaffByIdStaff rawStaff = response.data.staff!;
|
|
||||||
|
|
||||||
// Map the raw data connect object to the Domain Entity
|
|
||||||
return Staff(
|
|
||||||
id: rawStaff.id,
|
|
||||||
authProviderId: rawStaff.userId,
|
|
||||||
name: rawStaff.fullName,
|
|
||||||
email: rawStaff.email ?? '',
|
|
||||||
phone: rawStaff.phone,
|
|
||||||
avatar: rawStaff.photoUrl,
|
|
||||||
status: StaffStatus.active,
|
|
||||||
address: rawStaff.addres,
|
|
||||||
totalShifts: rawStaff.totalShifts,
|
|
||||||
averageRating: rawStaff.averageRating,
|
|
||||||
onTimeRate: rawStaff.onTimeRate,
|
|
||||||
noShowCount: rawStaff.noShowCount,
|
|
||||||
cancellationCount: rawStaff.cancellationCount,
|
|
||||||
reliabilityScore: rawStaff.reliabilityScore,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> signOut() async {
|
|
||||||
try {
|
|
||||||
await _service.auth.signOut();
|
|
||||||
_service.clearCache();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Error signing out: ${e.toString()}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
/// Repository interface for staff profile operations.
|
|
||||||
///
|
|
||||||
/// Defines the contract for accessing and managing staff profile data.
|
|
||||||
/// This interface lives in the domain layer and is implemented by the data layer.
|
|
||||||
///
|
|
||||||
/// Following Clean Architecture principles, this interface:
|
|
||||||
/// - Returns domain entities (Staff from shared domain package)
|
|
||||||
/// - Defines business requirements without implementation details
|
|
||||||
/// - Allows the domain layer to be independent of data sources
|
|
||||||
abstract interface class ProfileRepositoryInterface {
|
|
||||||
/// Fetches the staff profile for the current authenticated user.
|
|
||||||
///
|
|
||||||
/// Returns a [Staff] entity from the shared domain package containing
|
|
||||||
/// all profile information.
|
|
||||||
///
|
|
||||||
/// Throws an exception if the profile cannot be retrieved.
|
|
||||||
Future<Staff> getStaffProfile();
|
|
||||||
|
|
||||||
/// Signs out the current user.
|
|
||||||
///
|
|
||||||
/// Clears the user's session and authentication state.
|
|
||||||
/// Should be followed by navigation to the authentication flow.
|
|
||||||
Future<void> signOut();
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import 'package:krow_core/core.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
import '../repositories/profile_repository.dart';
|
|
||||||
|
|
||||||
/// Use case for fetching a staff member's extended profile information.
|
|
||||||
///
|
|
||||||
/// This use case:
|
|
||||||
/// 1. Fetches the [Staff] object from the repository
|
|
||||||
/// 2. Returns it directly to the presentation layer
|
|
||||||
///
|
|
||||||
class GetProfileUseCase implements UseCase<void, Staff> {
|
|
||||||
final ProfileRepositoryInterface _repository;
|
|
||||||
|
|
||||||
/// Creates a [GetProfileUseCase].
|
|
||||||
///
|
|
||||||
/// Requires a [ProfileRepositoryInterface] to interact with the profile data source.
|
|
||||||
const GetProfileUseCase(this._repository);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Staff> call([void params]) async {
|
|
||||||
// Fetch staff object from repository and return directly
|
|
||||||
return await _repository.getStaffProfile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import 'package:krow_core/core.dart';
|
|
||||||
|
|
||||||
import '../repositories/profile_repository.dart';
|
|
||||||
|
|
||||||
/// Use case for signing out the current user.
|
|
||||||
///
|
|
||||||
/// This use case delegates the sign-out logic to the [ProfileRepositoryInterface].
|
|
||||||
///
|
|
||||||
/// Following Clean Architecture principles, this use case:
|
|
||||||
/// - Encapsulates the sign-out business rule
|
|
||||||
/// - Depends only on the repository interface
|
|
||||||
/// - Keeps the domain layer independent of external frameworks
|
|
||||||
class SignOutUseCase implements NoInputUseCase<void> {
|
|
||||||
final ProfileRepositoryInterface _repository;
|
|
||||||
|
|
||||||
/// Creates a [SignOutUseCase].
|
|
||||||
///
|
|
||||||
/// Requires a [ProfileRepositoryInterface] to perform the sign-out operation.
|
|
||||||
const SignOutUseCase(this._repository);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> call() {
|
|
||||||
return _repository.signOut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../../domain/usecases/get_profile_usecase.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import '../../domain/usecases/sign_out_usecase.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import 'profile_state.dart';
|
import 'profile_state.dart';
|
||||||
|
|
||||||
/// Cubit for managing the Profile feature state.
|
/// Cubit for managing the Profile feature state.
|
||||||
@@ -9,12 +10,22 @@ import 'profile_state.dart';
|
|||||||
/// Handles loading profile data and user sign-out actions.
|
/// Handles loading profile data and user sign-out actions.
|
||||||
class ProfileCubit extends Cubit<ProfileState>
|
class ProfileCubit extends Cubit<ProfileState>
|
||||||
with BlocErrorHandler<ProfileState> {
|
with BlocErrorHandler<ProfileState> {
|
||||||
final GetProfileUseCase _getProfileUseCase;
|
final GetStaffProfileUseCase _getProfileUseCase;
|
||||||
final SignOutUseCase _signOutUseCase;
|
final SignOutStaffUseCase _signOutUseCase;
|
||||||
|
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase;
|
||||||
|
final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase;
|
||||||
|
final GetExperienceCompletionUseCase _getExperienceCompletionUseCase;
|
||||||
|
final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase;
|
||||||
|
|
||||||
/// Creates a [ProfileCubit] with the required use cases.
|
/// Creates a [ProfileCubit] with the required use cases.
|
||||||
ProfileCubit(this._getProfileUseCase, this._signOutUseCase)
|
ProfileCubit(
|
||||||
: super(const ProfileState());
|
this._getProfileUseCase,
|
||||||
|
this._signOutUseCase,
|
||||||
|
this._getPersonalInfoCompletionUseCase,
|
||||||
|
this._getEmergencyContactsCompletionUseCase,
|
||||||
|
this._getExperienceCompletionUseCase,
|
||||||
|
this._getTaxFormsCompletionUseCase,
|
||||||
|
) : super(const ProfileState());
|
||||||
|
|
||||||
/// Loads the staff member's profile.
|
/// Loads the staff member's profile.
|
||||||
///
|
///
|
||||||
@@ -27,7 +38,7 @@ class ProfileCubit extends Cubit<ProfileState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
final profile = await _getProfileUseCase();
|
final Staff profile = await _getProfileUseCase();
|
||||||
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
||||||
},
|
},
|
||||||
onError:
|
onError:
|
||||||
@@ -63,5 +74,61 @@ class ProfileCubit extends Cubit<ProfileState>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads personal information completion status.
|
||||||
|
Future<void> loadPersonalInfoCompletion() async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final bool isComplete = await _getPersonalInfoCompletionUseCase();
|
||||||
|
emit(state.copyWith(personalInfoComplete: isComplete));
|
||||||
|
},
|
||||||
|
onError: (String _) {
|
||||||
|
return state.copyWith(personalInfoComplete: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads emergency contacts completion status.
|
||||||
|
Future<void> loadEmergencyContactsCompletion() async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final bool isComplete = await _getEmergencyContactsCompletionUseCase();
|
||||||
|
emit(state.copyWith(emergencyContactsComplete: isComplete));
|
||||||
|
},
|
||||||
|
onError: (String _) {
|
||||||
|
return state.copyWith(emergencyContactsComplete: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads experience completion status.
|
||||||
|
Future<void> loadExperienceCompletion() async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final bool isComplete = await _getExperienceCompletionUseCase();
|
||||||
|
emit(state.copyWith(experienceComplete: isComplete));
|
||||||
|
},
|
||||||
|
onError: (String _) {
|
||||||
|
return state.copyWith(experienceComplete: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads tax forms completion status.
|
||||||
|
Future<void> loadTaxFormsCompletion() async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final bool isComplete = await _getTaxFormsCompletionUseCase();
|
||||||
|
emit(state.copyWith(taxFormsComplete: isComplete));
|
||||||
|
},
|
||||||
|
onError: (String _) {
|
||||||
|
return state.copyWith(taxFormsComplete: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,26 @@ class ProfileState extends Equatable {
|
|||||||
/// Error message if status is error
|
/// Error message if status is error
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Whether personal information is complete
|
||||||
|
final bool? personalInfoComplete;
|
||||||
|
|
||||||
|
/// Whether emergency contacts are complete
|
||||||
|
final bool? emergencyContactsComplete;
|
||||||
|
|
||||||
|
/// Whether experience information is complete
|
||||||
|
final bool? experienceComplete;
|
||||||
|
|
||||||
|
/// Whether tax forms are complete
|
||||||
|
final bool? taxFormsComplete;
|
||||||
|
|
||||||
const ProfileState({
|
const ProfileState({
|
||||||
this.status = ProfileStatus.initial,
|
this.status = ProfileStatus.initial,
|
||||||
this.profile,
|
this.profile,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.personalInfoComplete,
|
||||||
|
this.emergencyContactsComplete,
|
||||||
|
this.experienceComplete,
|
||||||
|
this.taxFormsComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a copy of this state with updated values.
|
/// Creates a copy of this state with updated values.
|
||||||
@@ -44,14 +60,30 @@ class ProfileState extends Equatable {
|
|||||||
ProfileStatus? status,
|
ProfileStatus? status,
|
||||||
Staff? profile,
|
Staff? profile,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
bool? personalInfoComplete,
|
||||||
|
bool? emergencyContactsComplete,
|
||||||
|
bool? experienceComplete,
|
||||||
|
bool? taxFormsComplete,
|
||||||
}) {
|
}) {
|
||||||
return ProfileState(
|
return ProfileState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
profile: profile ?? this.profile,
|
profile: profile ?? this.profile,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete,
|
||||||
|
emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete,
|
||||||
|
experienceComplete: experienceComplete ?? this.experienceComplete,
|
||||||
|
taxFormsComplete: taxFormsComplete ?? this.taxFormsComplete,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [status, profile, errorMessage];
|
List<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
profile,
|
||||||
|
errorMessage,
|
||||||
|
personalInfoComplete,
|
||||||
|
emergencyContactsComplete,
|
||||||
|
experienceComplete,
|
||||||
|
taxFormsComplete,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
import '../blocs/profile_cubit.dart';
|
import '../blocs/profile_cubit.dart';
|
||||||
import '../blocs/profile_state.dart';
|
import '../blocs/profile_state.dart';
|
||||||
import '../widgets/logout_button.dart';
|
import '../widgets/logout_button.dart';
|
||||||
import '../widgets/profile_header.dart';
|
import '../widgets/header/profile_header.dart';
|
||||||
import '../widgets/reliability_score_bar.dart';
|
import '../widgets/reliability_score_bar.dart';
|
||||||
import '../widgets/reliability_stats_card.dart';
|
import '../widgets/reliability_stats_card.dart';
|
||||||
import '../widgets/sections/index.dart';
|
import '../widgets/sections/index.dart';
|
||||||
@@ -25,129 +25,124 @@ class StaffProfilePage extends StatelessWidget {
|
|||||||
/// Creates a [StaffProfilePage].
|
/// Creates a [StaffProfilePage].
|
||||||
const StaffProfilePage({super.key});
|
const StaffProfilePage({super.key});
|
||||||
|
|
||||||
String _mapStatusToLevel(StaffStatus status) {
|
|
||||||
switch (status) {
|
|
||||||
case StaffStatus.active:
|
|
||||||
case StaffStatus.verified:
|
|
||||||
return 'Krower I';
|
|
||||||
case StaffStatus.pending:
|
|
||||||
case StaffStatus.completedProfile:
|
|
||||||
return 'Pending';
|
|
||||||
default:
|
|
||||||
return 'New';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSignOut(ProfileCubit cubit, ProfileState state) {
|
|
||||||
if (state.status != ProfileStatus.loading) {
|
|
||||||
cubit.signOut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ProfileCubit cubit = Modular.get<ProfileCubit>();
|
final ProfileCubit cubit = Modular.get<ProfileCubit>();
|
||||||
|
|
||||||
// Load profile data on first build
|
// Load profile data on first build if not already loaded
|
||||||
if (cubit.state.status == ProfileStatus.initial) {
|
if (cubit.state.status == ProfileStatus.initial) {
|
||||||
cubit.loadProfile();
|
cubit.loadProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: BlocConsumer<ProfileCubit, ProfileState>(
|
body: BlocProvider<ProfileCubit>.value(
|
||||||
bloc: cubit,
|
value: cubit,
|
||||||
listener: (BuildContext context, ProfileState state) {
|
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||||
if (state.status == ProfileStatus.signedOut) {
|
listener: (BuildContext context, ProfileState state) {
|
||||||
Modular.to.toGetStartedPage();
|
// Load completion statuses when profile loads successfully
|
||||||
} else if (state.status == ProfileStatus.error &&
|
if (state.status == ProfileStatus.loaded &&
|
||||||
state.errorMessage != null) {
|
state.personalInfoComplete == null) {
|
||||||
UiSnackbar.show(
|
cubit.loadPersonalInfoCompletion();
|
||||||
context,
|
cubit.loadEmergencyContactsCompletion();
|
||||||
message: translateErrorKey(state.errorMessage!),
|
cubit.loadExperienceCompletion();
|
||||||
type: UiSnackbarType.error,
|
cubit.loadTaxFormsCompletion();
|
||||||
);
|
}
|
||||||
}
|
|
||||||
},
|
if (state.status == ProfileStatus.signedOut) {
|
||||||
builder: (BuildContext context, ProfileState state) {
|
Modular.to.toGetStartedPage();
|
||||||
// Show loading spinner if status is loading
|
} else if (state.status == ProfileStatus.error &&
|
||||||
|
state.errorMessage != null) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: translateErrorKey(state.errorMessage!),
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (BuildContext context, ProfileState state) {
|
||||||
|
// Show loading spinner if status is loading
|
||||||
if (state.status == ProfileStatus.loading) {
|
if (state.status == ProfileStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == ProfileStatus.error) {
|
if (state.status == ProfileStatus.error) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.errorMessage != null
|
state.errorMessage != null
|
||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
: 'An error occurred',
|
: 'An error occurred',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: UiTypography.body1r.copyWith(
|
style: UiTypography.body1r.copyWith(
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Staff? profile = state.profile;
|
||||||
|
if (profile == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(bottom: UiConstants.space16),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
ProfileHeader(
|
||||||
|
fullName: profile.name,
|
||||||
|
photoUrl: profile.avatar,
|
||||||
|
),
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -UiConstants.space6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
spacing: UiConstants.space6,
|
||||||
|
children: <Widget>[
|
||||||
|
// Reliability Stats and Score
|
||||||
|
ReliabilityStatsCard(
|
||||||
|
totalShifts: profile.totalShifts,
|
||||||
|
averageRating: profile.averageRating,
|
||||||
|
onTimeRate: profile.onTimeRate,
|
||||||
|
noShowCount: profile.noShowCount,
|
||||||
|
cancellationCount: profile.cancellationCount,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Reliability Score Bar
|
||||||
|
ReliabilityScoreBar(
|
||||||
|
reliabilityScore: profile.reliabilityScore,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Ordered sections
|
||||||
|
const OnboardingSection(),
|
||||||
|
|
||||||
|
// Compliance section
|
||||||
|
const ComplianceSection(),
|
||||||
|
|
||||||
|
// Finance section
|
||||||
|
const FinanceSection(),
|
||||||
|
|
||||||
|
// Support section
|
||||||
|
const SupportSection(),
|
||||||
|
|
||||||
|
// Logout button at the bottom
|
||||||
|
const LogoutButton(),
|
||||||
|
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
final Staff? profile = state.profile;
|
|
||||||
if (profile == null) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space16),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
ProfileHeader(
|
|
||||||
fullName: profile.name,
|
|
||||||
level: _mapStatusToLevel(profile.status),
|
|
||||||
photoUrl: profile.avatar,
|
|
||||||
onSignOutTap: () => _onSignOut(cubit, state),
|
|
||||||
),
|
|
||||||
Transform.translate(
|
|
||||||
offset: const Offset(0, -UiConstants.space6),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space5,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
ReliabilityStatsCard(
|
|
||||||
totalShifts: profile.totalShifts,
|
|
||||||
averageRating: profile.averageRating,
|
|
||||||
onTimeRate: profile.onTimeRate,
|
|
||||||
noShowCount: profile.noShowCount,
|
|
||||||
cancellationCount: profile.cancellationCount,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
ReliabilityScoreBar(
|
|
||||||
reliabilityScore: profile.reliabilityScore,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
const OnboardingSection(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
const ComplianceSection(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
const FinanceSection(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
const SupportSection(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
const SettingsSection(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
LogoutButton(
|
|
||||||
onTap: () => _onSignOut(cubit, state),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'profile_level_badge.dart';
|
||||||
|
|
||||||
|
/// The header section of the staff profile page, containing avatar, name, and level.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, and spacing.
|
||||||
|
class ProfileHeader extends StatelessWidget {
|
||||||
|
/// Creates a [ProfileHeader].
|
||||||
|
const ProfileHeader({
|
||||||
|
super.key,
|
||||||
|
required this.fullName,
|
||||||
|
this.photoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The staff member's full name
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// Optional photo URL for the avatar
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space16,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: UiColors.primary,
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
bottom: Radius.circular(UiConstants.space6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
// Avatar Section
|
||||||
|
Container(
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: <Color>[
|
||||||
|
UiColors.accent,
|
||||||
|
UiColors.accent.withValues(alpha: 0.5),
|
||||||
|
UiColors.primaryForeground,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.foreground.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
backgroundImage: photoUrl != null
|
||||||
|
? NetworkImage(photoUrl!)
|
||||||
|
: null,
|
||||||
|
child: photoUrl == null
|
||||||
|
? Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: <Color>[
|
||||||
|
UiColors.accent,
|
||||||
|
UiColors.accent.withValues(alpha: 0.7),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
fullName.isNotEmpty
|
||||||
|
? fullName[0].toUpperCase()
|
||||||
|
: 'K',
|
||||||
|
style: UiTypography.displayM.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Text(fullName, style: UiTypography.headline2m.white),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
const ProfileLevelBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../blocs/profile_cubit.dart';
|
||||||
|
import '../../blocs/profile_state.dart';
|
||||||
|
|
||||||
|
/// A widget that displays the staff member's level badge.
|
||||||
|
///
|
||||||
|
/// The level is calculated based on the staff status from ProfileCubit and displayed
|
||||||
|
/// in a styled container with the design system tokens.
|
||||||
|
class ProfileLevelBadge extends StatelessWidget {
|
||||||
|
/// Creates a [ProfileLevelBadge].
|
||||||
|
const ProfileLevelBadge({super.key});
|
||||||
|
|
||||||
|
/// Maps staff status to a user-friendly level string.
|
||||||
|
String _mapStatusToLevel(StaffStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case StaffStatus.active:
|
||||||
|
case StaffStatus.verified:
|
||||||
|
return 'Krower I';
|
||||||
|
case StaffStatus.pending:
|
||||||
|
case StaffStatus.completedProfile:
|
||||||
|
return 'Pending';
|
||||||
|
default:
|
||||||
|
return 'New';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
|
builder: (BuildContext context, ProfileState state) {
|
||||||
|
final Staff? profile = state.profile;
|
||||||
|
if (profile == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String level = _mapStatusToLevel(profile.status);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space3,
|
||||||
|
vertical: UiConstants.space1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.space5),
|
||||||
|
),
|
||||||
|
child: Text(level, style: UiTypography.footnote1b.accent),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,73 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../blocs/profile_cubit.dart';
|
||||||
|
import '../blocs/profile_state.dart';
|
||||||
|
|
||||||
/// The sign-out button widget.
|
/// The sign-out button widget.
|
||||||
///
|
///
|
||||||
/// Uses design system tokens for all colors, typography, spacing, and icons.
|
/// Uses design system tokens for all colors, typography, spacing, and icons.
|
||||||
|
/// Handles logout logic when tapped and navigates to onboarding on success.
|
||||||
class LogoutButton extends StatelessWidget {
|
class LogoutButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
const LogoutButton({super.key});
|
||||||
|
|
||||||
const LogoutButton({super.key, required this.onTap});
|
/// Handles the sign-out action.
|
||||||
|
///
|
||||||
|
/// Checks if the profile is not currently loading, then triggers the
|
||||||
|
/// sign-out process via the ProfileCubit.
|
||||||
|
void _handleSignOut(BuildContext context, ProfileState state) {
|
||||||
|
if (state.status != ProfileStatus.loading) {
|
||||||
|
context.read<ProfileCubit>().signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i18n = t.staff.profile.header;
|
final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header;
|
||||||
|
|
||||||
return Container(
|
return BlocListener<ProfileCubit, ProfileState>(
|
||||||
width: double.infinity,
|
listener: (BuildContext context, ProfileState state) {
|
||||||
decoration: BoxDecoration(
|
if (state.status == ProfileStatus.signedOut) {
|
||||||
color: UiColors.bgPopup,
|
// Navigate to get started page after successful sign-out
|
||||||
borderRadius: UiConstants.radiusLg,
|
// This will be handled by the profile page listener
|
||||||
border: Border.all(color: UiColors.border),
|
}
|
||||||
),
|
},
|
||||||
child: Material(
|
child: Container(
|
||||||
color: UiColors.transparent,
|
width: double.infinity,
|
||||||
child: InkWell(
|
decoration: BoxDecoration(
|
||||||
onTap: onTap,
|
color: UiColors.bgPopup,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
child: Padding(
|
border: Border.all(color: UiColors.border),
|
||||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
),
|
||||||
child: Row(
|
child: Material(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
color: UiColors.transparent,
|
||||||
children: [
|
child: InkWell(
|
||||||
const Icon(
|
onTap: () {
|
||||||
UiIcons.logOut,
|
_handleSignOut(
|
||||||
color: UiColors.destructive,
|
context,
|
||||||
size: 20,
|
context.read<ProfileCubit>().state,
|
||||||
),
|
);
|
||||||
const SizedBox(width: UiConstants.space2),
|
},
|
||||||
Text(
|
borderRadius: UiConstants.radiusLg,
|
||||||
i18n.sign_out,
|
child: Padding(
|
||||||
style: UiTypography.body1m.textError,
|
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||||
),
|
child: Row(
|
||||||
],
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.logOut,
|
||||||
|
color: UiColors.destructive,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
i18n.sign_out,
|
||||||
|
style: UiTypography.body1m.textError,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
|
|
||||||
/// The header section of the staff profile page, containing avatar, name, level,
|
|
||||||
/// and a sign-out button.
|
|
||||||
///
|
|
||||||
/// Uses design system tokens for all colors, typography, and spacing.
|
|
||||||
class ProfileHeader extends StatelessWidget {
|
|
||||||
/// The staff member's full name
|
|
||||||
final String fullName;
|
|
||||||
|
|
||||||
/// The staff member's level (e.g., "Krower I")
|
|
||||||
final String level;
|
|
||||||
|
|
||||||
/// Optional photo URL for the avatar
|
|
||||||
final String? photoUrl;
|
|
||||||
|
|
||||||
/// Callback when sign out is tapped
|
|
||||||
final VoidCallback onSignOutTap;
|
|
||||||
|
|
||||||
/// Creates a [ProfileHeader].
|
|
||||||
const ProfileHeader({
|
|
||||||
super.key,
|
|
||||||
required this.fullName,
|
|
||||||
required this.level,
|
|
||||||
this.photoUrl,
|
|
||||||
required this.onSignOutTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final i18n = t.staff.profile.header;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
UiConstants.space5,
|
|
||||||
UiConstants.space5,
|
|
||||||
UiConstants.space5,
|
|
||||||
UiConstants.space16,
|
|
||||||
),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: UiColors.primary,
|
|
||||||
borderRadius: BorderRadius.vertical(
|
|
||||||
bottom: Radius.circular(UiConstants.space6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
bottom: false,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Top Bar
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
i18n.title,
|
|
||||||
style: UiTypography.headline4m.textSecondary,
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onSignOutTap,
|
|
||||||
child: Text(
|
|
||||||
i18n.sign_out,
|
|
||||||
style: UiTypography.body2m.copyWith(
|
|
||||||
color: UiColors.primaryForeground.withValues(alpha: 0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space8),
|
|
||||||
// Avatar Section
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.bottomRight,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 112,
|
|
||||||
height: 112,
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
UiColors.accent,
|
|
||||||
UiColors.accent.withValues(alpha: 0.5),
|
|
||||||
UiColors.primaryForeground,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.foreground.withValues(alpha: 0.2),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
|
||||||
width: 4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: UiColors.background,
|
|
||||||
backgroundImage: photoUrl != null
|
|
||||||
? NetworkImage(photoUrl!)
|
|
||||||
: null,
|
|
||||||
child: photoUrl == null
|
|
||||||
? Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
UiColors.accent,
|
|
||||||
UiColors.accent.withValues(alpha: 0.7),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
fullName.isNotEmpty
|
|
||||||
? fullName[0].toUpperCase()
|
|
||||||
: 'K',
|
|
||||||
style: UiTypography.displayM.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primaryForeground,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: UiColors.primary, width: 2),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.foreground.withValues(alpha: 0.1),
|
|
||||||
blurRadius: 4,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
UiIcons.camera,
|
|
||||||
size: 16,
|
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Text(
|
|
||||||
fullName,
|
|
||||||
style: UiTypography.headline3m.textPlaceholder,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space3,
|
|
||||||
vertical: UiConstants.space1,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.accent.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.space5),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
level,
|
|
||||||
style: UiTypography.footnote1b.accent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// An individual item within the profile menu grid.
|
/// An individual item within the profile menu grid.
|
||||||
///
|
///
|
||||||
/// Uses design system tokens for all colors, typography, spacing, and borders.
|
/// Uses design system tokens for all colors, typography, spacing, and borders.
|
||||||
class ProfileMenuItem extends StatelessWidget {
|
class ProfileMenuItem extends StatelessWidget {
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final bool? completed;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const ProfileMenuItem({
|
const ProfileMenuItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -18,6 +13,11 @@ class ProfileMenuItem extends StatelessWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool? completed;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -32,12 +32,12 @@ class ProfileMenuItem extends StatelessWidget {
|
|||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 1.0,
|
aspectRatio: 1.0,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -73,21 +73,22 @@ class ProfileMenuItem extends StatelessWidget {
|
|||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: completed! ? UiColors.primary : UiColors.error,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
color: completed!
|
color: completed!
|
||||||
? UiColors.primary
|
? UiColors.primary.withValues(alpha: 0.1)
|
||||||
: UiColors.primary.withValues(alpha: 0.1),
|
: UiColors.error.withValues(alpha: 0.15),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: completed!
|
child: completed!
|
||||||
? const Icon(
|
? const Icon(
|
||||||
UiIcons.check,
|
UiIcons.check,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: UiColors.primaryForeground,
|
color: UiColors.primary,
|
||||||
)
|
)
|
||||||
: Text(
|
: Text("!", style: UiTypography.footnote2b.textError),
|
||||||
"!",
|
|
||||||
style: UiTypography.footnote2b.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
import '../../blocs/profile_cubit.dart';
|
||||||
|
import '../../blocs/profile_state.dart';
|
||||||
import '../profile_menu_grid.dart';
|
import '../profile_menu_grid.dart';
|
||||||
import '../profile_menu_item.dart';
|
import '../profile_menu_item.dart';
|
||||||
import '../section_title.dart';
|
import '../section_title.dart';
|
||||||
@@ -11,6 +14,7 @@ import '../section_title.dart';
|
|||||||
/// Widget displaying the compliance section of the staff profile.
|
/// Widget displaying the compliance section of the staff profile.
|
||||||
///
|
///
|
||||||
/// This section contains menu items for tax forms and other compliance-related documents.
|
/// This section contains menu items for tax forms and other compliance-related documents.
|
||||||
|
/// Displays completion status for each item.
|
||||||
class ComplianceSection extends StatelessWidget {
|
class ComplianceSection extends StatelessWidget {
|
||||||
/// Creates a [ComplianceSection].
|
/// Creates a [ComplianceSection].
|
||||||
const ComplianceSection({super.key});
|
const ComplianceSection({super.key});
|
||||||
@@ -19,21 +23,26 @@ class ComplianceSection extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||||
|
|
||||||
return Column(
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
builder: (BuildContext context, ProfileState state) {
|
||||||
children: <Widget>[
|
return Column(
|
||||||
SectionTitle(i18n.sections.compliance),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
ProfileMenuGrid(
|
|
||||||
crossAxisCount: 3,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ProfileMenuItem(
|
SectionTitle(i18n.sections.compliance),
|
||||||
icon: UiIcons.file,
|
ProfileMenuGrid(
|
||||||
label: i18n.menu_items.tax_forms,
|
crossAxisCount: 3,
|
||||||
onTap: () => Modular.to.toTaxForms(),
|
children: <Widget>[
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: UiIcons.file,
|
||||||
|
label: i18n.menu_items.tax_forms,
|
||||||
|
completed: state.taxFormsComplete,
|
||||||
|
onTap: () => Modular.to.toTaxForms(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export 'compliance_section.dart';
|
export 'compliance_section.dart';
|
||||||
export 'finance_section.dart';
|
export 'finance_section.dart';
|
||||||
export 'onboarding_section.dart';
|
export 'onboarding_section.dart';
|
||||||
export 'settings_section.dart';
|
|
||||||
export 'support_section.dart';
|
export 'support_section.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
import '../../blocs/profile_cubit.dart';
|
||||||
|
import '../../blocs/profile_state.dart';
|
||||||
import '../profile_menu_grid.dart';
|
import '../profile_menu_grid.dart';
|
||||||
import '../profile_menu_item.dart';
|
import '../profile_menu_item.dart';
|
||||||
import '../section_title.dart';
|
import '../section_title.dart';
|
||||||
@@ -11,7 +14,7 @@ import '../section_title.dart';
|
|||||||
/// Widget displaying the onboarding section of the staff profile.
|
/// Widget displaying the onboarding section of the staff profile.
|
||||||
///
|
///
|
||||||
/// This section contains menu items for personal information, emergency contact,
|
/// This section contains menu items for personal information, emergency contact,
|
||||||
/// and work experience setup.
|
/// and work experience setup. Displays completion status for each item.
|
||||||
class OnboardingSection extends StatelessWidget {
|
class OnboardingSection extends StatelessWidget {
|
||||||
/// Creates an [OnboardingSection].
|
/// Creates an [OnboardingSection].
|
||||||
const OnboardingSection({super.key});
|
const OnboardingSection({super.key});
|
||||||
@@ -20,30 +23,37 @@ class OnboardingSection extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||||
|
|
||||||
return Column(
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
children: <Widget>[
|
builder: (BuildContext context, ProfileState state) {
|
||||||
SectionTitle(i18n.sections.onboarding),
|
return Column(
|
||||||
ProfileMenuGrid(
|
|
||||||
crossAxisCount: 3,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ProfileMenuItem(
|
SectionTitle(i18n.sections.onboarding),
|
||||||
icon: UiIcons.user,
|
ProfileMenuGrid(
|
||||||
label: i18n.menu_items.personal_info,
|
crossAxisCount: 3,
|
||||||
onTap: () => Modular.to.toPersonalInfo(),
|
children: <Widget>[
|
||||||
),
|
ProfileMenuItem(
|
||||||
ProfileMenuItem(
|
icon: UiIcons.user,
|
||||||
icon: UiIcons.phone,
|
label: i18n.menu_items.personal_info,
|
||||||
label: i18n.menu_items.emergency_contact,
|
completed: state.personalInfoComplete,
|
||||||
onTap: () => Modular.to.toEmergencyContact(),
|
onTap: () => Modular.to.toPersonalInfo(),
|
||||||
),
|
),
|
||||||
ProfileMenuItem(
|
ProfileMenuItem(
|
||||||
icon: UiIcons.briefcase,
|
icon: UiIcons.phone,
|
||||||
label: i18n.menu_items.experience,
|
label: i18n.menu_items.emergency_contact,
|
||||||
onTap: () => Modular.to.toExperience(),
|
completed: state.emergencyContactsComplete,
|
||||||
|
onTap: () => Modular.to.toEmergencyContact(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: UiIcons.briefcase,
|
||||||
|
label: i18n.menu_items.experience,
|
||||||
|
completed: state.experienceComplete,
|
||||||
|
onTap: () => Modular.to.toExperience(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
import 'data/repositories/profile_repository_impl.dart';
|
|
||||||
import 'domain/repositories/profile_repository.dart';
|
|
||||||
import 'domain/usecases/get_profile_usecase.dart';
|
|
||||||
import 'domain/usecases/sign_out_usecase.dart';
|
|
||||||
import 'presentation/blocs/profile_cubit.dart';
|
import 'presentation/blocs/profile_cubit.dart';
|
||||||
import 'presentation/pages/staff_profile_page.dart';
|
import 'presentation/pages/staff_profile_page.dart';
|
||||||
|
|
||||||
@@ -15,28 +12,56 @@ import 'presentation/pages/staff_profile_page.dart';
|
|||||||
/// following Clean Architecture principles.
|
/// following Clean Architecture principles.
|
||||||
///
|
///
|
||||||
/// Dependency flow:
|
/// Dependency flow:
|
||||||
/// - Repository implementation (ProfileRepositoryImpl) delegates to data_connect
|
/// - Use cases from data_connect layer (StaffConnectorRepository)
|
||||||
/// - Use cases depend on repository interface
|
|
||||||
/// - Cubit depends on use cases
|
/// - Cubit depends on use cases
|
||||||
class StaffProfileModule extends Module {
|
class StaffProfileModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repository implementation - delegates to data_connect
|
// StaffConnectorRepository intialization
|
||||||
i.addLazySingleton<ProfileRepositoryInterface>(
|
i.addLazySingleton<StaffConnectorRepository>(
|
||||||
ProfileRepositoryImpl.new,
|
() => StaffConnectorRepositoryImpl(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use cases - depend on repository interface
|
// Use cases from data_connect - depend on StaffConnectorRepository
|
||||||
i.addLazySingleton<GetProfileUseCase>(
|
i.addLazySingleton<GetStaffProfileUseCase>(
|
||||||
() => GetProfileUseCase(i.get<ProfileRepositoryInterface>()),
|
() =>
|
||||||
|
GetStaffProfileUseCase(repository: i.get<StaffConnectorRepository>()),
|
||||||
);
|
);
|
||||||
i.addLazySingleton<SignOutUseCase>(
|
i.addLazySingleton<SignOutStaffUseCase>(
|
||||||
() => SignOutUseCase(i.get<ProfileRepositoryInterface>()),
|
() => SignOutStaffUseCase(repository: i.get<StaffConnectorRepository>()),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<GetPersonalInfoCompletionUseCase>(
|
||||||
|
() => GetPersonalInfoCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<GetEmergencyContactsCompletionUseCase>(
|
||||||
|
() => GetEmergencyContactsCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<GetExperienceCompletionUseCase>(
|
||||||
|
() => GetExperienceCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<GetTaxFormsCompletionUseCase>(
|
||||||
|
() => GetTaxFormsCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Presentation layer - Cubit depends on use cases
|
// Presentation layer - Cubit as singleton to avoid recreation
|
||||||
i.add<ProfileCubit>(
|
// BlocProvider will use this same instance, preventing state emission after close
|
||||||
() => ProfileCubit(i.get<GetProfileUseCase>(), i.get<SignOutUseCase>()),
|
i.addSingleton<ProfileCubit>(
|
||||||
|
() => ProfileCubit(
|
||||||
|
i.get<GetStaffProfileUseCase>(),
|
||||||
|
i.get<SignOutStaffUseCase>(),
|
||||||
|
i.get<GetPersonalInfoCompletionUseCase>(),
|
||||||
|
i.get<GetEmergencyContactsCompletionUseCase>(),
|
||||||
|
i.get<GetExperienceCompletionUseCase>(),
|
||||||
|
i.get<GetTaxFormsCompletionUseCase>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
/// Language selection page for staff profile.
|
||||||
|
///
|
||||||
|
/// Displays available languages and allows the user to select their preferred
|
||||||
|
/// language. Changes are applied immediately via [LocaleBloc] and persisted.
|
||||||
|
/// Shows a snackbar when the language is successfully changed.
|
||||||
|
class LanguageSelectionPage extends StatelessWidget {
|
||||||
|
/// Creates a [LanguageSelectionPage].
|
||||||
|
const LanguageSelectionPage({super.key});
|
||||||
|
|
||||||
|
String _getLocalizedLanguageName(AppLocale locale) {
|
||||||
|
switch (locale) {
|
||||||
|
case AppLocale.en:
|
||||||
|
return 'English';
|
||||||
|
case AppLocale.es:
|
||||||
|
return 'Español';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLanguageChangedSnackbar(BuildContext context, String languageName) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: '${t.settings.change_language}: $languageName',
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
|
||||||
|
Modular.to
|
||||||
|
.pop(); // Close the language selection page after showing the snackbar
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: UiAppBar(
|
||||||
|
title: t.settings.change_language,
|
||||||
|
showBackButton: true,
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(1.0),
|
||||||
|
child: Container(color: UiColors.border, height: 1.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: BlocBuilder<LocaleBloc, LocaleState>(
|
||||||
|
builder: (BuildContext context, LocaleState state) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
children: <Widget>[
|
||||||
|
_buildLanguageOption(context, locale: AppLocale.en),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildLanguageOption(context, locale: AppLocale.es),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLanguageOption(
|
||||||
|
BuildContext context, {
|
||||||
|
required AppLocale locale,
|
||||||
|
}) {
|
||||||
|
final String label = _getLocalizedLanguageName(locale);
|
||||||
|
// Check if this option is currently selected.
|
||||||
|
final AppLocale currentLocale = LocaleSettings.currentLocale;
|
||||||
|
final bool isSelected = currentLocale == locale;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// Only proceed if selecting a different language
|
||||||
|
if (currentLocale != locale) {
|
||||||
|
Modular.get<LocaleBloc>().add(ChangeLocale(locale.flutterLocale));
|
||||||
|
_showLanguageChangedSnackbar(context, label);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
horizontal: UiConstants.space4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? UiColors.primary.withValues(alpha: 0.1)
|
||||||
|
: UiColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? UiColors.primary : UiColors.border,
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: isSelected
|
||||||
|
? UiTypography.body1b.copyWith(color: UiColors.primary)
|
||||||
|
: UiTypography.body1r,
|
||||||
|
),
|
||||||
|
if (isSelected) const Icon(UiIcons.check, color: UiColors.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
|
||||||
/// A form widget containing all personal information fields.
|
/// A form widget containing all personal information fields.
|
||||||
@@ -77,11 +79,73 @@ class PersonalInfoForm extends StatelessWidget {
|
|||||||
hint: i18n.locations_hint,
|
hint: i18n.locations_hint,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
_FieldLabel(text: 'Language'),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
_LanguageSelector(
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A language selector widget that displays the current language and navigates to language selection page.
|
||||||
|
class _LanguageSelector extends StatelessWidget {
|
||||||
|
const _LanguageSelector({
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
String _getLanguageLabel(AppLocale locale) {
|
||||||
|
switch (locale) {
|
||||||
|
case AppLocale.en:
|
||||||
|
return 'English';
|
||||||
|
case AppLocale.es:
|
||||||
|
return 'Español';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final AppLocale currentLocale = LocaleSettings.currentLocale;
|
||||||
|
final String currentLanguage = _getLanguageLabel(currentLocale);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: enabled
|
||||||
|
? () => Modular.to.pushNamed(StaffPaths.languageSelection)
|
||||||
|
: null,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space3,
|
||||||
|
vertical: UiConstants.space3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
currentLanguage,
|
||||||
|
style: UiTypography.body2r.textPrimary,
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
UiIcons.chevronRight,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A label widget for form fields.
|
/// A label widget for form fields.
|
||||||
/// A label widget for form fields.
|
/// A label widget for form fields.
|
||||||
class _FieldLabel extends StatelessWidget {
|
class _FieldLabel extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import 'data/repositories/personal_info_repository_impl.dart';
|
import 'data/repositories/personal_info_repository_impl.dart';
|
||||||
import 'domain/repositories/personal_info_repository_interface.dart';
|
import 'domain/repositories/personal_info_repository_interface.dart';
|
||||||
@@ -7,6 +8,7 @@ import 'domain/usecases/get_personal_info_usecase.dart';
|
|||||||
import 'domain/usecases/update_personal_info_usecase.dart';
|
import 'domain/usecases/update_personal_info_usecase.dart';
|
||||||
import 'presentation/blocs/personal_info_bloc.dart';
|
import 'presentation/blocs/personal_info_bloc.dart';
|
||||||
import 'presentation/pages/personal_info_page.dart';
|
import 'presentation/pages/personal_info_page.dart';
|
||||||
|
import 'presentation/pages/language_selection_page.dart';
|
||||||
|
|
||||||
/// The entry module for the Staff Profile Info feature.
|
/// The entry module for the Staff Profile Info feature.
|
||||||
///
|
///
|
||||||
@@ -23,7 +25,8 @@ class StaffProfileInfoModule extends Module {
|
|||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repository
|
// Repository
|
||||||
i.addLazySingleton<PersonalInfoRepositoryInterface>(
|
i.addLazySingleton<PersonalInfoRepositoryInterface>(
|
||||||
PersonalInfoRepositoryImpl.new);
|
PersonalInfoRepositoryImpl.new,
|
||||||
|
);
|
||||||
|
|
||||||
// Use Cases - delegate business logic to repository
|
// Use Cases - delegate business logic to repository
|
||||||
i.addLazySingleton<GetPersonalInfoUseCase>(
|
i.addLazySingleton<GetPersonalInfoUseCase>(
|
||||||
@@ -45,13 +48,18 @@ class StaffProfileInfoModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(
|
r.child(
|
||||||
'/personal-info',
|
StaffPaths.childRoute(
|
||||||
|
StaffPaths.onboardingPersonalInfo,
|
||||||
|
StaffPaths.onboardingPersonalInfo,
|
||||||
|
),
|
||||||
child: (BuildContext context) => const PersonalInfoPage(),
|
child: (BuildContext context) => const PersonalInfoPage(),
|
||||||
);
|
);
|
||||||
// Alias with trailing slash to be tolerant of external deep links
|
|
||||||
r.child(
|
r.child(
|
||||||
'/personal-info/',
|
StaffPaths.childRoute(
|
||||||
child: (BuildContext context) => const PersonalInfoPage(),
|
StaffPaths.onboardingPersonalInfo,
|
||||||
|
StaffPaths.languageSelection,
|
||||||
|
),
|
||||||
|
child: (BuildContext context) => const LanguageSelectionPage(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
final GetPendingAssignmentsUseCase getPendingAssignments;
|
final GetPendingAssignmentsUseCase getPendingAssignments;
|
||||||
final GetCancelledShiftsUseCase getCancelledShifts;
|
final GetCancelledShiftsUseCase getCancelledShifts;
|
||||||
final GetHistoryShiftsUseCase getHistoryShifts;
|
final GetHistoryShiftsUseCase getHistoryShifts;
|
||||||
|
final GetProfileCompletionUseCase getProfileCompletion;
|
||||||
|
|
||||||
ShiftsBloc({
|
ShiftsBloc({
|
||||||
required this.getMyShifts,
|
required this.getMyShifts,
|
||||||
@@ -29,6 +31,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
required this.getPendingAssignments,
|
required this.getPendingAssignments,
|
||||||
required this.getCancelledShifts,
|
required this.getCancelledShifts,
|
||||||
required this.getHistoryShifts,
|
required this.getHistoryShifts,
|
||||||
|
required this.getProfileCompletion,
|
||||||
}) : super(ShiftsInitial()) {
|
}) : super(ShiftsInitial()) {
|
||||||
on<LoadShiftsEvent>(_onLoadShifts);
|
on<LoadShiftsEvent>(_onLoadShifts);
|
||||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||||
@@ -36,6 +39,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
on<LoadFindFirstEvent>(_onLoadFindFirst);
|
on<LoadFindFirstEvent>(_onLoadFindFirst);
|
||||||
on<LoadShiftsForRangeEvent>(_onLoadShiftsForRange);
|
on<LoadShiftsForRangeEvent>(_onLoadShiftsForRange);
|
||||||
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
|
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
|
||||||
|
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onLoadShifts(
|
Future<void> _onLoadShifts(
|
||||||
@@ -268,6 +272,25 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onCheckProfileCompletion(
|
||||||
|
CheckProfileCompletionEvent event,
|
||||||
|
Emitter<ShiftsState> emit,
|
||||||
|
) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ShiftsLoaded) return;
|
||||||
|
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final bool isComplete = await getProfileCompletion();
|
||||||
|
emit(currentState.copyWith(profileComplete: isComplete));
|
||||||
|
},
|
||||||
|
onError: (String errorKey) {
|
||||||
|
return currentState.copyWith(profileComplete: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||||
|
|||||||
@@ -54,3 +54,10 @@ class DeclineShiftEvent extends ShiftsEvent {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => [shiftId];
|
List<Object?> get props => [shiftId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CheckProfileCompletionEvent extends ShiftsEvent {
|
||||||
|
const CheckProfileCompletionEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ShiftsLoaded extends ShiftsState {
|
|||||||
final bool myShiftsLoaded;
|
final bool myShiftsLoaded;
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
final String jobType;
|
final String jobType;
|
||||||
|
final bool? profileComplete;
|
||||||
|
|
||||||
const ShiftsLoaded({
|
const ShiftsLoaded({
|
||||||
required this.myShifts,
|
required this.myShifts,
|
||||||
@@ -39,6 +40,7 @@ class ShiftsLoaded extends ShiftsState {
|
|||||||
required this.myShiftsLoaded,
|
required this.myShiftsLoaded,
|
||||||
required this.searchQuery,
|
required this.searchQuery,
|
||||||
required this.jobType,
|
required this.jobType,
|
||||||
|
this.profileComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
ShiftsLoaded copyWith({
|
ShiftsLoaded copyWith({
|
||||||
@@ -54,6 +56,7 @@ class ShiftsLoaded extends ShiftsState {
|
|||||||
bool? myShiftsLoaded,
|
bool? myShiftsLoaded,
|
||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
String? jobType,
|
String? jobType,
|
||||||
|
bool? profileComplete,
|
||||||
}) {
|
}) {
|
||||||
return ShiftsLoaded(
|
return ShiftsLoaded(
|
||||||
myShifts: myShifts ?? this.myShifts,
|
myShifts: myShifts ?? this.myShifts,
|
||||||
@@ -68,6 +71,7 @@ class ShiftsLoaded extends ShiftsState {
|
|||||||
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
||||||
searchQuery: searchQuery ?? this.searchQuery,
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
jobType: jobType ?? this.jobType,
|
jobType: jobType ?? this.jobType,
|
||||||
|
profileComplete: profileComplete ?? this.profileComplete,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ class ShiftsLoaded extends ShiftsState {
|
|||||||
myShiftsLoaded,
|
myShiftsLoaded,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
jobType,
|
jobType,
|
||||||
|
profileComplete ?? '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
_bloc.add(LoadAvailableShiftsEvent());
|
_bloc.add(LoadAvailableShiftsEvent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Check profile completion
|
||||||
|
_bloc.add(const CheckProfileCompletionEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -138,15 +140,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
// Tabs
|
// Tabs
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_buildTab(
|
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||||
"myshifts",
|
Expanded(
|
||||||
t.staff_shifts.tabs.my_shifts,
|
child: _buildTab(
|
||||||
UiIcons.calendar,
|
"myshifts",
|
||||||
myShifts.length,
|
t.staff_shifts.tabs.my_shifts,
|
||||||
showCount: myShiftsLoaded,
|
UiIcons.calendar,
|
||||||
enabled: !blockTabsForFind,
|
myShifts.length,
|
||||||
),
|
showCount: myShiftsLoaded,
|
||||||
const SizedBox(width: UiConstants.space2),
|
enabled: !blockTabsForFind && (state.profileComplete ?? false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||||
|
const SizedBox(width: UiConstants.space2)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
_buildTab(
|
_buildTab(
|
||||||
"find",
|
"find",
|
||||||
t.staff_shifts.tabs.find_work,
|
t.staff_shifts.tabs.find_work,
|
||||||
@@ -155,15 +165,25 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
showCount: availableLoaded,
|
showCount: availableLoaded,
|
||||||
enabled: baseLoaded,
|
enabled: baseLoaded,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||||
_buildTab(
|
const SizedBox(width: UiConstants.space2)
|
||||||
"history",
|
else
|
||||||
t.staff_shifts.tabs.history,
|
const SizedBox.shrink(),
|
||||||
UiIcons.clock,
|
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||||
historyShifts.length,
|
Expanded(
|
||||||
showCount: historyLoaded,
|
child: _buildTab(
|
||||||
enabled: !blockTabsForFind && baseLoaded,
|
"history",
|
||||||
),
|
t.staff_shifts.tabs.history,
|
||||||
|
UiIcons.clock,
|
||||||
|
historyShifts.length,
|
||||||
|
showCount: historyLoaded,
|
||||||
|
enabled: !blockTabsForFind &&
|
||||||
|
baseLoaded &&
|
||||||
|
(state.profileComplete ?? false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'domain/repositories/shifts_repository_interface.dart';
|
import 'domain/repositories/shifts_repository_interface.dart';
|
||||||
import 'data/repositories_impl/shifts_repository_impl.dart';
|
import 'data/repositories_impl/shifts_repository_impl.dart';
|
||||||
import 'domain/usecases/get_my_shifts_usecase.dart';
|
import 'domain/usecases/get_my_shifts_usecase.dart';
|
||||||
@@ -17,6 +18,18 @@ import 'presentation/pages/shifts_page.dart';
|
|||||||
class StaffShiftsModule extends Module {
|
class StaffShiftsModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
|
// StaffConnectorRepository for profile completion
|
||||||
|
i.addLazySingleton<StaffConnectorRepository>(
|
||||||
|
() => StaffConnectorRepositoryImpl(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Profile completion use case
|
||||||
|
i.addLazySingleton<GetProfileCompletionUseCase>(
|
||||||
|
() => GetProfileCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
|
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
|
||||||
|
|
||||||
@@ -32,7 +45,14 @@ class StaffShiftsModule extends Module {
|
|||||||
i.add(GetShiftDetailsUseCase.new);
|
i.add(GetShiftDetailsUseCase.new);
|
||||||
|
|
||||||
// Bloc
|
// Bloc
|
||||||
i.add(ShiftsBloc.new);
|
i.add(() => ShiftsBloc(
|
||||||
|
getMyShifts: i.get(),
|
||||||
|
getAvailableShifts: i.get(),
|
||||||
|
getPendingAssignments: i.get(),
|
||||||
|
getCancelledShifts: i.get(),
|
||||||
|
getHistoryShifts: i.get(),
|
||||||
|
getProfileCompletion: i.get(),
|
||||||
|
));
|
||||||
i.add(ShiftDetailsBloc.new);
|
i.add(ShiftDetailsBloc.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
|
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
|
||||||
|
|
||||||
class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
||||||
StaffMainCubit() : super(const StaffMainState()) {
|
StaffMainCubit({
|
||||||
|
required GetProfileCompletionUseCase getProfileCompletionUsecase,
|
||||||
|
}) : _getProfileCompletionUsecase = getProfileCompletionUsecase,
|
||||||
|
super(const StaffMainState()) {
|
||||||
Modular.to.addListener(_onRouteChanged);
|
Modular.to.addListener(_onRouteChanged);
|
||||||
_onRouteChanged();
|
_onRouteChanged();
|
||||||
|
_loadProfileCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
|
||||||
|
|
||||||
void _onRouteChanged() {
|
void _onRouteChanged() {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
final String path = Modular.to.path;
|
final String path = Modular.to.path;
|
||||||
@@ -32,6 +40,22 @@ class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads the profile completion status.
|
||||||
|
Future<void> _loadProfileCompletion() async {
|
||||||
|
try {
|
||||||
|
final isComplete = await _getProfileCompletionUsecase();
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(state.copyWith(isProfileComplete: isComplete));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If there's an error, allow access to all features
|
||||||
|
debugPrint('Error loading profile completion: $e');
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(state.copyWith(isProfileComplete: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void navigateToTab(int index) {
|
void navigateToTab(int index) {
|
||||||
if (index == state.currentIndex) return;
|
if (index == state.currentIndex) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart';
|
|||||||
class StaffMainState extends Equatable {
|
class StaffMainState extends Equatable {
|
||||||
const StaffMainState({
|
const StaffMainState({
|
||||||
this.currentIndex = 2, // Default to Home
|
this.currentIndex = 2, // Default to Home
|
||||||
|
this.isProfileComplete = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
|
final bool isProfileComplete;
|
||||||
|
|
||||||
StaffMainState copyWith({int? currentIndex}) {
|
StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) {
|
||||||
return StaffMainState(currentIndex: currentIndex ?? this.currentIndex);
|
return StaffMainState(
|
||||||
|
currentIndex: currentIndex ?? this.currentIndex,
|
||||||
|
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[currentIndex];
|
List<Object> get props => <Object>[currentIndex, isProfileComplete];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
abstract class StaffMainRoutes {
|
|
||||||
static const String modulePath = '/worker-main';
|
|
||||||
|
|
||||||
static const String shifts = '/shifts';
|
|
||||||
static const String payments = '/payments';
|
|
||||||
static const String home = '/home';
|
|
||||||
static const String clockIn = '/clock-in';
|
|
||||||
static const String profile = '/profile';
|
|
||||||
|
|
||||||
// Full paths
|
|
||||||
static const String shiftsFull = '$modulePath$shifts';
|
|
||||||
static const String paymentsFull = '$modulePath$payments';
|
|
||||||
static const String homeFull = '$modulePath$home';
|
|
||||||
static const String clockInFull = '$modulePath$clockIn';
|
|
||||||
static const String profileFull = '$modulePath$profile';
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||||
|
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
|
||||||
|
import 'package:staff_main/src/utils/index.dart';
|
||||||
|
|
||||||
/// A custom bottom navigation bar for the Staff app.
|
/// A custom bottom navigation bar for the Staff app.
|
||||||
///
|
///
|
||||||
@@ -10,6 +13,10 @@ import 'package:flutter/material.dart';
|
|||||||
/// and follows the KROW Design System guidelines. It displays five tabs:
|
/// and follows the KROW Design System guidelines. It displays five tabs:
|
||||||
/// Shifts, Payments, Home, Clock In, and Profile.
|
/// Shifts, Payments, Home, Clock In, and Profile.
|
||||||
///
|
///
|
||||||
|
/// Navigation items are gated by profile completion status. Items marked with
|
||||||
|
/// [StaffNavItem.requireProfileCompletion] are only visible when the profile
|
||||||
|
/// is complete.
|
||||||
|
///
|
||||||
/// The widget uses:
|
/// The widget uses:
|
||||||
/// - [UiColors] for all color values
|
/// - [UiColors] for all color values
|
||||||
/// - [UiTypography] for text styling
|
/// - [UiTypography] for text styling
|
||||||
@@ -36,82 +43,60 @@ class StaffMainBottomBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Translations.of(context);
|
|
||||||
// Staff App colors from design system
|
// Staff App colors from design system
|
||||||
// Using primary (Blue) for active as per prototype
|
// Using primary (Blue) for active as per prototype
|
||||||
const Color activeColor = UiColors.primary;
|
const Color activeColor = UiColors.primary;
|
||||||
const Color inactiveColor = UiColors.textInactive;
|
const Color inactiveColor = UiColors.textInactive;
|
||||||
|
|
||||||
return Stack(
|
return BlocBuilder<StaffMainCubit, StaffMainState>(
|
||||||
clipBehavior: Clip.none,
|
builder: (BuildContext context, StaffMainState state) {
|
||||||
children: <Widget>[
|
final bool isProfileComplete = state.isProfileComplete;
|
||||||
// Glassmorphic background with blur effect
|
|
||||||
Positioned.fill(
|
return Stack(
|
||||||
child: ClipRect(
|
clipBehavior: Clip.none,
|
||||||
child: BackdropFilter(
|
children: <Widget>[
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
// Glassmorphic background with blur effect
|
||||||
child: Container(
|
Positioned.fill(
|
||||||
decoration: BoxDecoration(
|
child: ClipRect(
|
||||||
color: UiColors.white.withValues(alpha: 0.85),
|
child: BackdropFilter(
|
||||||
border: Border(
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
top: BorderSide(
|
child: Container(
|
||||||
color: UiColors.black.withValues(alpha: 0.1),
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withValues(alpha: 0.85),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: UiColors.black.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Navigation items
|
||||||
),
|
Container(
|
||||||
// Navigation items
|
padding: EdgeInsets.only(
|
||||||
Container(
|
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2,
|
||||||
padding: EdgeInsets.only(
|
top: UiConstants.space4,
|
||||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2,
|
|
||||||
top: UiConstants.space4,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: <Widget>[
|
|
||||||
_buildNavItem(
|
|
||||||
index: 0,
|
|
||||||
icon: UiIcons.briefcase,
|
|
||||||
label: t.staff.main.tabs.shifts,
|
|
||||||
activeColor: activeColor,
|
|
||||||
inactiveColor: inactiveColor,
|
|
||||||
),
|
),
|
||||||
_buildNavItem(
|
child: Row(
|
||||||
index: 1,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
icon: UiIcons.dollar,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
label: t.staff.main.tabs.payments,
|
children: <Widget>[
|
||||||
activeColor: activeColor,
|
...defaultStaffNavItems.map(
|
||||||
inactiveColor: inactiveColor,
|
(item) => _buildNavItem(
|
||||||
|
item: item,
|
||||||
|
activeColor: activeColor,
|
||||||
|
inactiveColor: inactiveColor,
|
||||||
|
isProfileComplete: isProfileComplete,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_buildNavItem(
|
),
|
||||||
index: 2,
|
],
|
||||||
icon: UiIcons.home,
|
);
|
||||||
label: t.staff.main.tabs.home,
|
},
|
||||||
activeColor: activeColor,
|
|
||||||
inactiveColor: inactiveColor,
|
|
||||||
),
|
|
||||||
_buildNavItem(
|
|
||||||
index: 3,
|
|
||||||
icon: UiIcons.clock,
|
|
||||||
label: t.staff.main.tabs.clock_in,
|
|
||||||
activeColor: activeColor,
|
|
||||||
inactiveColor: inactiveColor,
|
|
||||||
),
|
|
||||||
_buildNavItem(
|
|
||||||
index: 4,
|
|
||||||
icon: UiIcons.users,
|
|
||||||
label: t.staff.main.tabs.profile,
|
|
||||||
activeColor: activeColor,
|
|
||||||
inactiveColor: inactiveColor,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,30 +107,37 @@ class StaffMainBottomBar extends StatelessWidget {
|
|||||||
/// - Spacing uses [UiConstants.space1]
|
/// - Spacing uses [UiConstants.space1]
|
||||||
/// - Typography uses [UiTypography.footnote2m]
|
/// - Typography uses [UiTypography.footnote2m]
|
||||||
/// - Colors are passed as parameters from design system
|
/// - Colors are passed as parameters from design system
|
||||||
|
///
|
||||||
|
/// Items with [item.requireProfileCompletion] = true are hidden when
|
||||||
|
/// [isProfileComplete] is false.
|
||||||
Widget _buildNavItem({
|
Widget _buildNavItem({
|
||||||
required int index,
|
required StaffNavItem item,
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
required Color activeColor,
|
required Color activeColor,
|
||||||
required Color inactiveColor,
|
required Color inactiveColor,
|
||||||
|
required bool isProfileComplete,
|
||||||
}) {
|
}) {
|
||||||
final bool isSelected = currentIndex == index;
|
// Hide item if profile completion is required but not complete
|
||||||
|
if (item.requireProfileCompletion && !isProfileComplete) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool isSelected = currentIndex == item.index;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => onTap(index),
|
onTap: () => onTap(item.index),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
item.icon,
|
||||||
color: isSelected ? activeColor : inactiveColor,
|
color: isSelected ? activeColor : inactiveColor,
|
||||||
size: UiConstants.iconLg,
|
size: UiConstants.iconLg,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
label,
|
item.label,
|
||||||
style: UiTypography.footnote2m.copyWith(
|
style: UiTypography.footnote2m.copyWith(
|
||||||
color: isSelected ? activeColor : inactiveColor,
|
color: isSelected ? activeColor : inactiveColor,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:staff_attire/staff_attire.dart';
|
import 'package:staff_attire/staff_attire.dart';
|
||||||
import 'package:staff_availability/staff_availability.dart';
|
import 'package:staff_availability/staff_availability.dart';
|
||||||
import 'package:staff_bank_account/staff_bank_account.dart';
|
import 'package:staff_bank_account/staff_bank_account.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:staff_certificates/staff_certificates.dart';
|
|||||||
import 'package:staff_clock_in/staff_clock_in.dart';
|
import 'package:staff_clock_in/staff_clock_in.dart';
|
||||||
import 'package:staff_documents/staff_documents.dart';
|
import 'package:staff_documents/staff_documents.dart';
|
||||||
import 'package:staff_emergency_contact/staff_emergency_contact.dart';
|
import 'package:staff_emergency_contact/staff_emergency_contact.dart';
|
||||||
|
import 'package:staff_faqs/staff_faqs.dart';
|
||||||
import 'package:staff_home/staff_home.dart';
|
import 'package:staff_home/staff_home.dart';
|
||||||
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||||
import 'package:staff_main/src/presentation/pages/staff_main_page.dart';
|
import 'package:staff_main/src/presentation/pages/staff_main_page.dart';
|
||||||
@@ -18,13 +20,28 @@ import 'package:staff_profile_experience/staff_profile_experience.dart';
|
|||||||
import 'package:staff_profile_info/staff_profile_info.dart';
|
import 'package:staff_profile_info/staff_profile_info.dart';
|
||||||
import 'package:staff_shifts/staff_shifts.dart';
|
import 'package:staff_shifts/staff_shifts.dart';
|
||||||
import 'package:staff_tax_forms/staff_tax_forms.dart';
|
import 'package:staff_tax_forms/staff_tax_forms.dart';
|
||||||
import 'package:staff_faqs/staff_faqs.dart';
|
|
||||||
import 'package:staff_time_card/staff_time_card.dart';
|
import 'package:staff_time_card/staff_time_card.dart';
|
||||||
|
|
||||||
class StaffMainModule extends Module {
|
class StaffMainModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addSingleton(StaffMainCubit.new);
|
// Register the StaffConnectorRepository from data_connect
|
||||||
|
i.addSingleton<StaffConnectorRepository>(
|
||||||
|
StaffConnectorRepositoryImpl.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the use case from data_connect
|
||||||
|
i.addSingleton(
|
||||||
|
() => GetProfileCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
i.add(
|
||||||
|
() => StaffMainCubit(
|
||||||
|
getProfileCompletionUsecase: i.get<GetProfileCompletionUseCase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,7 +73,7 @@ class StaffMainModule extends Module {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
r.module(
|
r.module(
|
||||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo).replaceFirst('/personal-info', ''),
|
StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo),
|
||||||
module: StaffProfileInfoModule(),
|
module: StaffProfileInfoModule(),
|
||||||
);
|
);
|
||||||
r.module(
|
r.module(
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'staff_nav_item.dart';
|
||||||
|
export 'staff_nav_items_config.dart';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Represents a single navigation item in the staff main bottom navigation bar.
|
||||||
|
///
|
||||||
|
/// This data class encapsulates all properties needed to define a navigation item,
|
||||||
|
/// making it easy to add, remove, or modify items in the bottom bar without
|
||||||
|
/// touching the UI code.
|
||||||
|
class StaffNavItem {
|
||||||
|
/// Creates a [StaffNavItem].
|
||||||
|
const StaffNavItem({
|
||||||
|
required this.index,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.tabKey,
|
||||||
|
this.requireProfileCompletion = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The index of this navigation item in the bottom bar.
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
/// The icon to display for this navigation item.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// The label text to display below the icon.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// The unique key identifying this tab in the main navigation system.
|
||||||
|
///
|
||||||
|
/// This is used internally for routing and state management.
|
||||||
|
final String tabKey;
|
||||||
|
|
||||||
|
/// Whether this navigation item requires the user's profile to be complete.
|
||||||
|
///
|
||||||
|
/// If true, this item may be disabled or show a prompt until the profile
|
||||||
|
/// is fully completed. This is useful for gating access to features that
|
||||||
|
/// require profile information.
|
||||||
|
final bool requireProfileCompletion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:staff_main/src/utils/staff_nav_item.dart';
|
||||||
|
|
||||||
|
/// Predefined navigation items for the Staff app bottom navigation bar.
|
||||||
|
///
|
||||||
|
/// This list defines all available navigation items. To add, remove, or modify
|
||||||
|
/// items, simply update this list. The UI will automatically adapt.
|
||||||
|
final List<StaffNavItem> defaultStaffNavItems = [
|
||||||
|
StaffNavItem(
|
||||||
|
index: 0,
|
||||||
|
icon: UiIcons.briefcase,
|
||||||
|
label: 'Shifts',
|
||||||
|
tabKey: 'shifts',
|
||||||
|
requireProfileCompletion: false,
|
||||||
|
),
|
||||||
|
StaffNavItem(
|
||||||
|
index: 1,
|
||||||
|
icon: UiIcons.dollar,
|
||||||
|
label: 'Payments',
|
||||||
|
tabKey: 'payments',
|
||||||
|
requireProfileCompletion: true,
|
||||||
|
),
|
||||||
|
StaffNavItem(
|
||||||
|
index: 2,
|
||||||
|
icon: UiIcons.home,
|
||||||
|
label: 'Home',
|
||||||
|
tabKey: 'home',
|
||||||
|
requireProfileCompletion: false,
|
||||||
|
),
|
||||||
|
StaffNavItem(
|
||||||
|
index: 3,
|
||||||
|
icon: UiIcons.clock,
|
||||||
|
label: 'Clock In',
|
||||||
|
tabKey: 'clock_in',
|
||||||
|
requireProfileCompletion: true,
|
||||||
|
),
|
||||||
|
StaffNavItem(
|
||||||
|
index: 4,
|
||||||
|
icon: UiIcons.users,
|
||||||
|
label: 'Profile',
|
||||||
|
tabKey: 'profile',
|
||||||
|
requireProfileCompletion: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -21,7 +21,9 @@ dependencies:
|
|||||||
core_localization:
|
core_localization:
|
||||||
path: ../../../core_localization
|
path: ../../../core_localization
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../../krow_core
|
path: ../../../core
|
||||||
|
krow_data_connect:
|
||||||
|
path: ../../../data_connect
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
staff_home:
|
staff_home:
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ If a user request is vague:
|
|||||||
* **DO NOT** add 3rd party packages without checking `apps/mobile/packages/core` first.
|
* **DO NOT** add 3rd party packages without checking `apps/mobile/packages/core` first.
|
||||||
* **DO NOT** add `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only.
|
* **DO NOT** add `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only.
|
||||||
* **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations.
|
* **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations.
|
||||||
* **Dependency Injection**: Use Flutter Modular for BLoC and UseCase injection in `Module.routes()`.
|
* **Dependency Injection**: Use Flutter Modular for BLoC (never use `addSingleton` for Blocs, always use `add` method) and UseCase injection in `Module.routes()`.
|
||||||
|
|
||||||
## 8. Error Handling
|
## 8. Error Handling
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ graph TD
|
|||||||
### 2.2 Features (`apps/mobile/packages/features/<APP_NAME>/<FEATURE_NAME>`)
|
### 2.2 Features (`apps/mobile/packages/features/<APP_NAME>/<FEATURE_NAME>`)
|
||||||
- **Role**: Vertical slices of user-facing functionality.
|
- **Role**: Vertical slices of user-facing functionality.
|
||||||
- **Internal Structure**:
|
- **Internal Structure**:
|
||||||
- `domain/`: Feature-specific Use Cases and Repository Interfaces.
|
- `domain/`: Feature-specific Use Cases(always extend the apps/mobile/packages/core/lib/src/domain/usecases/usecase.dart abstract clas) and Repository Interfaces.
|
||||||
- `data/`: Repository Implementations.
|
- `data/`: Repository Implementations.
|
||||||
- `presentation/`:
|
- `presentation/`:
|
||||||
- Pages, BLoCs, Widgets.
|
- Pages, BLoCs, Widgets.
|
||||||
- For performance make the pages as `StatelessWidget` and move the state management to the BLoC or `StatefulWidget` to an external separate widget file.
|
- For performance make the pages as `StatelessWidget` and move the state management to the BLoC (always use a BlocProvider when providing the BLoC to the widget tree) or `StatefulWidget` to an external separate widget file.
|
||||||
- **Responsibilities**:
|
- **Responsibilities**:
|
||||||
- **Presentation**: UI Pages, Modular Routes.
|
- **Presentation**: UI Pages, Modular Routes.
|
||||||
- **State Management**: BLoCs / Cubits.
|
- **State Management**: BLoCs / Cubits.
|
||||||
@@ -85,10 +85,18 @@ graph TD
|
|||||||
### 2.4 Data Connect (`apps/mobile/packages/data_connect`)
|
### 2.4 Data Connect (`apps/mobile/packages/data_connect`)
|
||||||
- **Role**: Interface Adapter for Backend Access (Datasource Layer).
|
- **Role**: Interface Adapter for Backend Access (Datasource Layer).
|
||||||
- **Responsibilities**:
|
- **Responsibilities**:
|
||||||
- Implement Firebase Data Connect connector and service layer.
|
- **Connectors**: Centralized repository implementations for each backend connector (see `03-data-connect-connectors-pattern.md`)
|
||||||
- Map Domain Entities to/from Data Connect generated code.
|
- One connector per backend connector domain (staff, order, user, etc.)
|
||||||
- Handle Firebase exceptions and map to domain failures.
|
- Repository interfaces and use cases defined at domain level
|
||||||
- Provide centralized `DataConnectService` with session management.
|
- Repository implementations query backend and map responses
|
||||||
|
- Implement Firebase Data Connect connector and service layer
|
||||||
|
- Map Domain Entities to/from Data Connect generated code
|
||||||
|
- Handle Firebase exceptions and map to domain failures
|
||||||
|
- Provide centralized `DataConnectService` with session management
|
||||||
|
- **RESTRICTION**:
|
||||||
|
- NO feature-specific logic. Connectors are domain-neutral and reusable.
|
||||||
|
- All queries must follow Clean Architecture (domain → data layers)
|
||||||
|
- See `03-data-connect-connectors-pattern.md` for detailed pattern documentation
|
||||||
|
|
||||||
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
||||||
- **Role**: Visual language and component library.
|
- **Role**: Visual language and component library.
|
||||||
@@ -195,3 +203,176 @@ Each app (`staff` and `client`) has different role requirements and session patt
|
|||||||
- **Session Store**: `ClientSessionStore` with `ClientSession(user: User, business: ClientBusinessSession?)`
|
- **Session Store**: `ClientSessionStore` with `ClientSession(user: User, business: ClientBusinessSession?)`
|
||||||
- **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null
|
- **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null
|
||||||
- **Navigation**: On auth → `Modular.to.toClientHome()`, on unauth → `Modular.to.toInitialPage()`
|
- **Navigation**: On auth → `Modular.to.toClientHome()`, on unauth → `Modular.to.toInitialPage()`
|
||||||
|
|
||||||
|
## 7. Data Connect Connectors Pattern
|
||||||
|
|
||||||
|
See **`03-data-connect-connectors-pattern.md`** for comprehensive documentation on:
|
||||||
|
- How connector repositories work
|
||||||
|
- How to add queries to existing connectors
|
||||||
|
- How to create new connectors
|
||||||
|
- Integration patterns with features
|
||||||
|
- Benefits and anti-patterns
|
||||||
|
|
||||||
|
**Quick Reference**:
|
||||||
|
- All backend queries centralized in `apps/mobile/packages/data_connect/lib/src/connectors/`
|
||||||
|
- One connector per backend connector domain (staff, order, user, etc.)
|
||||||
|
- Each connector follows Clean Architecture (domain interfaces + data implementations)
|
||||||
|
- Features use connector repositories through dependency injection
|
||||||
|
- Results in zero query duplication and single source of truth
|
||||||
|
|
||||||
|
## 8. Prop Drilling Prevention & Direct BLoC Access
|
||||||
|
|
||||||
|
### 8.1 The Problem: Prop Drilling
|
||||||
|
|
||||||
|
Passing data through intermediate widgets creates maintenance headaches:
|
||||||
|
- Every intermediate widget must accept and forward props
|
||||||
|
- Changes to data structure ripple through multiple widget constructors
|
||||||
|
- Reduces code clarity and increases cognitive load
|
||||||
|
|
||||||
|
**Anti-Pattern Example**:
|
||||||
|
```dart
|
||||||
|
// ❌ BAD: Drilling status through 3 levels
|
||||||
|
ProfilePage(status: status)
|
||||||
|
→ ProfileHeader(status: status)
|
||||||
|
→ ProfileLevelBadge(status: status) // Only widget that needs it!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 The Solution: Direct BLoC Access with BlocBuilder
|
||||||
|
|
||||||
|
Use `BlocBuilder` to access BLoC state directly in leaf widgets:
|
||||||
|
|
||||||
|
**Correct Pattern**:
|
||||||
|
```dart
|
||||||
|
// ✅ GOOD: ProfileLevelBadge accesses ProfileCubit directly
|
||||||
|
class ProfileLevelBadge extends StatelessWidget {
|
||||||
|
const ProfileLevelBadge({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final Staff? profile = state.profile;
|
||||||
|
if (profile == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final level = _mapStatusToLevel(profile.status);
|
||||||
|
return LevelBadgeUI(level: level);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Guidelines for Avoiding Prop Drilling
|
||||||
|
|
||||||
|
1. **Leaf Widgets Get Data from BLoC**: Widgets that need specific data should access it directly via BlocBuilder
|
||||||
|
2. **Container Widgets Stay Simple**: Parent widgets like `ProfileHeader` only manage layout and positioning
|
||||||
|
3. **No Unnecessary Props**: Don't pass data to intermediate widgets unless they need it for UI construction
|
||||||
|
4. **Single Responsibility**: Each widget should have one reason to exist
|
||||||
|
|
||||||
|
**Decision Tree**:
|
||||||
|
```
|
||||||
|
Does this widget need data?
|
||||||
|
├─ YES, and it's a leaf widget → Use BlocBuilder
|
||||||
|
├─ YES, and it's a container → Use BlocBuilder in child, not parent
|
||||||
|
└─ NO → Don't add prop to constructor
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. BLoC Lifecycle & State Emission Safety
|
||||||
|
|
||||||
|
### 9.1 The Problem: StateError After Dispose
|
||||||
|
|
||||||
|
When async operations complete after a BLoC is closed, attempting to emit state causes:
|
||||||
|
```
|
||||||
|
StateError: Cannot emit new states after calling close
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Causes**:
|
||||||
|
1. **Transient BLoCs**: `BlocProvider(create:)` creates new instance on every rebuild → disposed prematurely
|
||||||
|
2. **Singleton Disposal**: Multiple BlocProviders disposing same singleton instance
|
||||||
|
3. **Navigation During Async**: User navigates away while `loadProfile()` is still running
|
||||||
|
|
||||||
|
### 9.2 The Solution: Singleton BLoCs + Error Handler Defensive Wrapping
|
||||||
|
|
||||||
|
#### Step 1: Register as Singleton
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ✅ GOOD: ProfileCubit as singleton
|
||||||
|
i.addSingleton<ProfileCubit>(
|
||||||
|
() => ProfileCubit(useCase1, useCase2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ❌ BAD: Creates new instance each time
|
||||||
|
i.add(ProfileCubit.new);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Use BlocProvider.value() for Singletons
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ✅ GOOD: Use singleton instance
|
||||||
|
ProfileCubit cubit = Modular.get<ProfileCubit>();
|
||||||
|
BlocProvider<ProfileCubit>.value(
|
||||||
|
value: cubit, // Reuse same instance
|
||||||
|
child: MyWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ BAD: Creates duplicate instance
|
||||||
|
BlocProvider<ProfileCubit>(
|
||||||
|
create: (_) => Modular.get<ProfileCubit>(), // Wrong!
|
||||||
|
child: MyWidget(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Defensive Error Handling in BlocErrorHandler Mixin
|
||||||
|
|
||||||
|
The `BlocErrorHandler<S>` mixin provides `_safeEmit()` wrapper:
|
||||||
|
|
||||||
|
**Location**: `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart`
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void _safeEmit(void Function(S) emit, S state) {
|
||||||
|
try {
|
||||||
|
emit(state);
|
||||||
|
} on StateError catch (e) {
|
||||||
|
// Bloc was closed before emit - log and continue gracefully
|
||||||
|
developer.log(
|
||||||
|
'Could not emit state: ${e.message}. Bloc may have been disposed.',
|
||||||
|
name: runtimeType.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in Cubits/Blocs**:
|
||||||
|
```dart
|
||||||
|
Future<void> loadProfile() async {
|
||||||
|
emit(state.copyWith(status: ProfileStatus.loading));
|
||||||
|
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final profile = await getProfile();
|
||||||
|
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
||||||
|
// ✅ If BLoC disposed before emit, _safeEmit catches StateError gracefully
|
||||||
|
},
|
||||||
|
onError: (errorKey) {
|
||||||
|
return state.copyWith(status: ProfileStatus.error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Pattern Summary
|
||||||
|
|
||||||
|
| Pattern | When to Use | Risk |
|
||||||
|
|---------|------------|------|
|
||||||
|
| Singleton + BlocProvider.value() | Long-lived features (Profile, Shifts, etc.) | Low - instance persists |
|
||||||
|
| Transient + BlocProvider(create:) | Temporary widgets (Dialogs, Overlays) | Medium - requires careful disposal |
|
||||||
|
| Direct BlocBuilder | Leaf widgets needing data | Low - no registration needed |
|
||||||
|
|
||||||
|
**Remember**:
|
||||||
|
- Use **singletons** for feature-level cubits accessed from multiple pages
|
||||||
|
- Use **transient** only for temporary UI states
|
||||||
|
- Always wrap emit() in `_safeEmit()` via `BlocErrorHandler` mixin
|
||||||
|
- Test navigation away during async operations to verify graceful handling
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
273
docs/MOBILE/03-data-connect-connectors-pattern.md
Normal file
273
docs/MOBILE/03-data-connect-connectors-pattern.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Data Connect Connectors Pattern
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the **Data Connect Connectors** pattern implemented in the KROW mobile app. This pattern centralizes all backend query logic by mirroring backend connector structure in the mobile data layer.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
**Without Connectors Pattern:**
|
||||||
|
- Each feature creates its own repository implementation
|
||||||
|
- Multiple features query the same backend connector → duplication
|
||||||
|
- When backend queries change, updates needed in multiple places
|
||||||
|
- No reusability across features
|
||||||
|
|
||||||
|
**Example Problem:**
|
||||||
|
```
|
||||||
|
staff_main/
|
||||||
|
└── data/repositories/profile_completion_repository_impl.dart ← queries staff connector
|
||||||
|
profile/
|
||||||
|
└── data/repositories/profile_repository_impl.dart ← also queries staff connector
|
||||||
|
onboarding/
|
||||||
|
└── data/repositories/personal_info_repository_impl.dart ← also queries staff connector
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution: Connectors in Data Connect Package
|
||||||
|
|
||||||
|
All backend connector queries are implemented once in a centralized location, following the backend structure.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/mobile/packages/data_connect/lib/src/connectors/
|
||||||
|
├── staff/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── repositories/
|
||||||
|
│ │ │ └── staff_connector_repository.dart (interface)
|
||||||
|
│ │ └── usecases/
|
||||||
|
│ │ └── get_profile_completion_usecase.dart
|
||||||
|
│ └── data/
|
||||||
|
│ └── repositories/
|
||||||
|
│ └── staff_connector_repository_impl.dart (implementation)
|
||||||
|
├── order/
|
||||||
|
├── user/
|
||||||
|
├── emergency_contact/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Maps to backend structure:**
|
||||||
|
```
|
||||||
|
backend/dataconnect/connector/
|
||||||
|
├── staff/
|
||||||
|
├── order/
|
||||||
|
├── user/
|
||||||
|
├── emergency_contact/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clean Architecture Layers
|
||||||
|
|
||||||
|
Each connector follows Clean Architecture with three layers:
|
||||||
|
|
||||||
|
### Domain Layer (`connectors/{name}/domain/`)
|
||||||
|
|
||||||
|
**Repository Interface:**
|
||||||
|
```dart
|
||||||
|
// staff_connector_repository.dart
|
||||||
|
abstract interface class StaffConnectorRepository {
|
||||||
|
Future<bool> getProfileCompletion();
|
||||||
|
Future<Staff> getStaffById(String id);
|
||||||
|
// ... more queries
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
```dart
|
||||||
|
// get_profile_completion_usecase.dart
|
||||||
|
class GetProfileCompletionUseCase {
|
||||||
|
GetProfileCompletionUseCase({required StaffConnectorRepository repository});
|
||||||
|
Future<bool> call() => _repository.getProfileCompletion();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Pure Dart, no framework dependencies
|
||||||
|
- Stable, business-focused contracts
|
||||||
|
- One interface per connector
|
||||||
|
- One use case per query or related query group
|
||||||
|
|
||||||
|
### Data Layer (`connectors/{name}/data/`)
|
||||||
|
|
||||||
|
**Repository Implementation:**
|
||||||
|
```dart
|
||||||
|
// staff_connector_repository_impl.dart
|
||||||
|
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||||
|
final DataConnectService _service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> getProfileCompletion() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final staffId = await _service.getStaffId();
|
||||||
|
final response = await _service.connector
|
||||||
|
.getStaffProfileCompletion(id: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return _isProfileComplete(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Implements domain repository interface
|
||||||
|
- Uses `DataConnectService` to execute queries
|
||||||
|
- Maps backend response types to domain models
|
||||||
|
- Contains mapping/transformation logic only
|
||||||
|
- Handles type safety with generated Data Connect types
|
||||||
|
|
||||||
|
## Integration Pattern
|
||||||
|
|
||||||
|
### Step 1: Feature Needs Data
|
||||||
|
|
||||||
|
Feature (e.g., `staff_main`) needs profile completion status.
|
||||||
|
|
||||||
|
### Step 2: Use Connector Repository
|
||||||
|
|
||||||
|
Instead of creating a local repository, feature uses the connector:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// staff_main_module.dart
|
||||||
|
class StaffMainModule extends Module {
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
|
// Register connector repository from data_connect
|
||||||
|
i.addSingleton<StaffConnectorRepository>(
|
||||||
|
StaffConnectorRepositoryImpl.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Feature creates its own use case wrapper if needed
|
||||||
|
i.addSingleton(
|
||||||
|
() => GetProfileCompletionUseCase(
|
||||||
|
repository: i.get<StaffConnectorRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// BLoC uses the use case
|
||||||
|
i.addSingleton(
|
||||||
|
() => StaffMainCubit(
|
||||||
|
getProfileCompletionUsecase: i.get<GetProfileCompletionUseCase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: BLoC Uses It
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class StaffMainCubit extends Cubit<StaffMainState> {
|
||||||
|
StaffMainCubit({required GetProfileCompletionUseCase usecase}) {
|
||||||
|
_loadProfileCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProfileCompletion() async {
|
||||||
|
final isComplete = await _getProfileCompletionUsecase();
|
||||||
|
emit(state.copyWith(isProfileComplete: isComplete));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Export Pattern
|
||||||
|
|
||||||
|
Connectors are exported from `krow_data_connect` for easy access:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/krow_data_connect.dart
|
||||||
|
export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||||
|
export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart';
|
||||||
|
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features import:**
|
||||||
|
```dart
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Queries to Existing Connector
|
||||||
|
|
||||||
|
When backend adds `getStaffById()` query to staff connector:
|
||||||
|
|
||||||
|
1. **Add to interface:**
|
||||||
|
```dart
|
||||||
|
abstract interface class StaffConnectorRepository {
|
||||||
|
Future<Staff> getStaffById(String id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Implement in repository:**
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<Staff> getStaffById(String id) async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final response = await _service.connector
|
||||||
|
.getStaffById(id: id)
|
||||||
|
.execute();
|
||||||
|
return _mapToStaff(response.data.staff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use in features:**
|
||||||
|
```dart
|
||||||
|
// Any feature can now use it
|
||||||
|
final staff = await i.get<StaffConnectorRepository>().getStaffById(id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Connector
|
||||||
|
|
||||||
|
When backend adds new connector (e.g., `order`):
|
||||||
|
|
||||||
|
1. Create directory: `apps/mobile/packages/data_connect/lib/src/connectors/order/`
|
||||||
|
|
||||||
|
2. Create domain layer with repository interface and use cases
|
||||||
|
|
||||||
|
3. Create data layer with repository implementation
|
||||||
|
|
||||||
|
4. Export from `krow_data_connect.dart`
|
||||||
|
|
||||||
|
5. Features can immediately start using it
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **No Duplication** - Query implemented once, used by many features
|
||||||
|
✅ **Single Source of Truth** - Backend change → update one place
|
||||||
|
✅ **Clean Separation** - Connector logic separate from feature logic
|
||||||
|
✅ **Reusability** - Any feature can request any connector data
|
||||||
|
✅ **Testability** - Mock the connector repo to test features
|
||||||
|
✅ **Scalability** - Easy to add new connectors as backend grows
|
||||||
|
✅ **Mirrors Backend** - Mobile structure mirrors backend structure
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
❌ **DON'T**: Implement query logic in feature repository
|
||||||
|
❌ **DON'T**: Duplicate queries across multiple repositories
|
||||||
|
❌ **DON'T**: Put mapping logic in features
|
||||||
|
❌ **DON'T**: Call `DataConnectService` directly from BLoCs
|
||||||
|
|
||||||
|
**DO**: Use connector repositories through use cases in features.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
### Staff Connector
|
||||||
|
|
||||||
|
**Location**: `apps/mobile/packages/data_connect/lib/src/connectors/staff/`
|
||||||
|
|
||||||
|
**Available Queries**:
|
||||||
|
- `getProfileCompletion()` - Returns bool indicating if profile is complete
|
||||||
|
- Checks: personal info, emergency contacts, tax forms, experience (skills/industries)
|
||||||
|
|
||||||
|
**Used By**:
|
||||||
|
- `staff_main` - Guards bottom nav items requiring profile completion
|
||||||
|
|
||||||
|
**Backend Queries Used**:
|
||||||
|
- `backend/dataconnect/connector/staff/queries/profile_completion.gql`
|
||||||
|
|
||||||
|
## Future Expansion
|
||||||
|
|
||||||
|
As the app grows, additional connectors will be added:
|
||||||
|
- `order_connector_repository` (queries from `backend/dataconnect/connector/order/`)
|
||||||
|
- `user_connector_repository` (queries from `backend/dataconnect/connector/user/`)
|
||||||
|
- `emergency_contact_connector_repository` (queries from `backend/dataconnect/connector/emergencyContact/`)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Each following the same Clean Architecture pattern implemented for Staff Connector.
|
||||||
Reference in New Issue
Block a user