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
|
||||
|
||||
@@ -107,8 +107,7 @@ class StaffPaths {
|
||||
/// Path format: `/worker-main/shift-details/{shiftId}`
|
||||
///
|
||||
/// Example: `/worker-main/shift-details/shift123`
|
||||
static String shiftDetails(String shiftId) =>
|
||||
'$shiftDetailsRoute/$shiftId';
|
||||
static String shiftDetails(String shiftId) => '$shiftDetailsRoute/$shiftId';
|
||||
|
||||
// ==========================================================================
|
||||
// ONBOARDING & PROFILE SECTIONS
|
||||
@@ -117,8 +116,17 @@ class StaffPaths {
|
||||
/// Personal information onboarding.
|
||||
///
|
||||
/// Collect basic personal information during staff onboarding.
|
||||
static const String onboardingPersonalInfo =
|
||||
'/worker-main/onboarding/personal-info/';
|
||||
static const String onboardingPersonalInfo = '/worker-main/personal-info/';
|
||||
|
||||
// ==========================================================================
|
||||
// PERSONAL INFORMATION & PREFERENCES
|
||||
// ==========================================================================
|
||||
|
||||
/// Language selection page.
|
||||
///
|
||||
/// Allows staff to select their preferred language for the app interface.
|
||||
static const String languageSelection =
|
||||
'/worker-main/personal-info/language-selection/';
|
||||
|
||||
/// Emergency contact information.
|
||||
///
|
||||
|
||||
@@ -17,3 +17,14 @@ export 'src/services/mixins/session_handler_mixin.dart';
|
||||
|
||||
export 'src/session/staff_session_store.dart';
|
||||
export 'src/services/mixins/data_error_handler.dart';
|
||||
|
||||
// Export Staff Connector repositories and use cases
|
||||
export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart';
|
||||
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
@@ -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
|
||||
krow_domain:
|
||||
path: ../domain
|
||||
krow_core:
|
||||
path: ../core
|
||||
flutter_modular: ^6.3.0
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_core: ^4.4.0
|
||||
firebase_auth: ^6.1.4
|
||||
krow_core: ^0.0.1
|
||||
|
||||
@@ -5,15 +5,12 @@ publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
sdk: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
@@ -30,10 +27,12 @@ dependencies:
|
||||
path: ../view_orders
|
||||
billing:
|
||||
path: ../billing
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
|
||||
# Intentionally commenting these out as they might not exist yet
|
||||
# client_settings:
|
||||
# path: ../settings
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -390,6 +390,12 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
||||
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 total = 0;
|
||||
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.
|
||||
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.
|
||||
class ReorderUseCase implements UseCase<Future<void>, ReorderArguments> {
|
||||
class ReorderUseCase implements UseCase<ReorderArguments, void> {
|
||||
const ReorderUseCase(this._repository);
|
||||
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@@ -85,7 +85,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
Modular.to.navigate(
|
||||
'/client-main/orders/',
|
||||
ClientPaths.orders,
|
||||
arguments: <String, dynamic>{
|
||||
'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_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
class ReportsPage extends StatefulWidget {
|
||||
const ReportsPage({super.key});
|
||||
@@ -36,8 +37,8 @@ class _ReportsPageState extends State<ReportsPage>
|
||||
DateTime.now(),
|
||||
),
|
||||
(
|
||||
DateTime(DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1,
|
||||
1),
|
||||
DateTime(
|
||||
DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, 1),
|
||||
DateTime.now(),
|
||||
),
|
||||
];
|
||||
@@ -102,8 +103,7 @@ class _ReportsPageState extends State<ReportsPage>
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
Modular.to.navigate('/client-main/home'),
|
||||
onTap: () => Modular.to.toClientHome(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -209,8 +209,8 @@ class _ReportsPageState extends State<ReportsPage>
|
||||
}
|
||||
|
||||
final summary = (state as ReportsSummaryLoaded).summary;
|
||||
final currencyFmt =
|
||||
NumberFormat.currency(symbol: '\$', decimalDigits: 0);
|
||||
final currencyFmt = NumberFormat.currency(
|
||||
symbol: '\$', decimalDigits: 0);
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
@@ -261,8 +261,7 @@ class _ReportsPageState extends State<ReportsPage>
|
||||
icon: UiIcons.trendingUp,
|
||||
label: context
|
||||
.t.client_reports.metrics.fill_rate.label,
|
||||
value:
|
||||
'${summary.fillRate.toStringAsFixed(0)}%',
|
||||
value: '${summary.fillRate.toStringAsFixed(0)}%',
|
||||
badgeText: context
|
||||
.t.client_reports.metrics.fill_rate.badge,
|
||||
badgeColor: UiColors.tagInProgress,
|
||||
@@ -271,12 +270,12 @@ class _ReportsPageState extends State<ReportsPage>
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.clock,
|
||||
label: context.t.client_reports.metrics
|
||||
.avg_fill_time.label,
|
||||
label: context
|
||||
.t.client_reports.metrics.avg_fill_time.label,
|
||||
value:
|
||||
'${summary.avgFillTimeHours.toStringAsFixed(1)} hrs',
|
||||
badgeText: context.t.client_reports.metrics
|
||||
.avg_fill_time.badge,
|
||||
badgeText: context
|
||||
.t.client_reports.metrics.avg_fill_time.badge,
|
||||
badgeColor: UiColors.tagInProgress,
|
||||
badgeTextColor: UiColors.textLink,
|
||||
iconColor: UiColors.iconActive,
|
||||
@@ -474,8 +473,7 @@ class _MetricCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
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/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/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/performance/performance_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/pages/coverage_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/no_show_report_page.dart';
|
||||
@@ -26,7 +24,6 @@ class ReportsModule extends Module {
|
||||
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
|
||||
i.add<DailyOpsBloc>(DailyOpsBloc.new);
|
||||
i.add<SpendBloc>(SpendBloc.new);
|
||||
i.add<CoverageBloc>(CoverageBloc.new);
|
||||
i.add<ForecastBloc>(ForecastBloc.new);
|
||||
i.add<PerformanceBloc>(PerformanceBloc.new);
|
||||
i.add<NoShowBloc>(NoShowBloc.new);
|
||||
@@ -41,6 +38,5 @@ class ReportsModule extends Module {
|
||||
r.child('/forecast', child: (_) => const ForecastReportPage());
|
||||
r.child('/performance', child: (_) => const PerformanceReportPage());
|
||||
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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../blocs/client_settings_bloc.dart';
|
||||
|
||||
/// A widget that displays the log out button.
|
||||
@@ -59,7 +58,7 @@ class SettingsLogout extends StatelessWidget {
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
content: Text(
|
||||
t.client_settings.profile.log_out_confirmation,
|
||||
'Are you sure you want to log out?',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
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:krow_core/core.dart';
|
||||
import '../../domain/usecases/get_profile_usecase.dart';
|
||||
import '../../domain/usecases/sign_out_usecase.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'profile_state.dart';
|
||||
|
||||
/// Cubit for managing the Profile feature state.
|
||||
@@ -9,12 +10,22 @@ import 'profile_state.dart';
|
||||
/// Handles loading profile data and user sign-out actions.
|
||||
class ProfileCubit extends Cubit<ProfileState>
|
||||
with BlocErrorHandler<ProfileState> {
|
||||
final GetProfileUseCase _getProfileUseCase;
|
||||
final SignOutUseCase _signOutUseCase;
|
||||
final GetStaffProfileUseCase _getProfileUseCase;
|
||||
final SignOutStaffUseCase _signOutUseCase;
|
||||
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase;
|
||||
final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase;
|
||||
final GetExperienceCompletionUseCase _getExperienceCompletionUseCase;
|
||||
final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase;
|
||||
|
||||
/// Creates a [ProfileCubit] with the required use cases.
|
||||
ProfileCubit(this._getProfileUseCase, this._signOutUseCase)
|
||||
: super(const ProfileState());
|
||||
ProfileCubit(
|
||||
this._getProfileUseCase,
|
||||
this._signOutUseCase,
|
||||
this._getPersonalInfoCompletionUseCase,
|
||||
this._getEmergencyContactsCompletionUseCase,
|
||||
this._getExperienceCompletionUseCase,
|
||||
this._getTaxFormsCompletionUseCase,
|
||||
) : super(const ProfileState());
|
||||
|
||||
/// Loads the staff member's profile.
|
||||
///
|
||||
@@ -27,7 +38,7 @@ class ProfileCubit extends Cubit<ProfileState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final profile = await _getProfileUseCase();
|
||||
final Staff profile = await _getProfileUseCase();
|
||||
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
||||
},
|
||||
onError:
|
||||
@@ -63,5 +74,61 @@ class ProfileCubit extends Cubit<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
|
||||
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({
|
||||
this.status = ProfileStatus.initial,
|
||||
this.profile,
|
||||
this.errorMessage,
|
||||
this.personalInfoComplete,
|
||||
this.emergencyContactsComplete,
|
||||
this.experienceComplete,
|
||||
this.taxFormsComplete,
|
||||
});
|
||||
|
||||
/// Creates a copy of this state with updated values.
|
||||
@@ -44,14 +60,30 @@ class ProfileState extends Equatable {
|
||||
ProfileStatus? status,
|
||||
Staff? profile,
|
||||
String? errorMessage,
|
||||
bool? personalInfoComplete,
|
||||
bool? emergencyContactsComplete,
|
||||
bool? experienceComplete,
|
||||
bool? taxFormsComplete,
|
||||
}) {
|
||||
return ProfileState(
|
||||
status: status ?? this.status,
|
||||
profile: profile ?? this.profile,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete,
|
||||
emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete,
|
||||
experienceComplete: experienceComplete ?? this.experienceComplete,
|
||||
taxFormsComplete: taxFormsComplete ?? this.taxFormsComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<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_state.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_stats_card.dart';
|
||||
import '../widgets/sections/index.dart';
|
||||
@@ -25,38 +25,29 @@ class StaffProfilePage extends StatelessWidget {
|
||||
/// Creates a [StaffProfilePage].
|
||||
const StaffProfilePage({super.key});
|
||||
|
||||
String _mapStatusToLevel(StaffStatus status) {
|
||||
switch (status) {
|
||||
case StaffStatus.active:
|
||||
case StaffStatus.verified:
|
||||
return 'Krower I';
|
||||
case StaffStatus.pending:
|
||||
case StaffStatus.completedProfile:
|
||||
return 'Pending';
|
||||
default:
|
||||
return 'New';
|
||||
}
|
||||
}
|
||||
|
||||
void _onSignOut(ProfileCubit cubit, ProfileState state) {
|
||||
if (state.status != ProfileStatus.loading) {
|
||||
cubit.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final 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) {
|
||||
cubit.loadProfile();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
bloc: cubit,
|
||||
body: BlocProvider<ProfileCubit>.value(
|
||||
value: cubit,
|
||||
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
listener: (BuildContext context, ProfileState state) {
|
||||
// Load completion statuses when profile loads successfully
|
||||
if (state.status == ProfileStatus.loaded &&
|
||||
state.personalInfoComplete == null) {
|
||||
cubit.loadPersonalInfoCompletion();
|
||||
cubit.loadEmergencyContactsCompletion();
|
||||
cubit.loadExperienceCompletion();
|
||||
cubit.loadTaxFormsCompletion();
|
||||
}
|
||||
|
||||
if (state.status == ProfileStatus.signedOut) {
|
||||
Modular.to.toGetStartedPage();
|
||||
} else if (state.status == ProfileStatus.error &&
|
||||
@@ -102,9 +93,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
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),
|
||||
@@ -113,7 +102,9 @@ class StaffProfilePage extends StatelessWidget {
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
// Reliability Stats and Score
|
||||
ReliabilityStatsCard(
|
||||
totalShifts: profile.totalShifts,
|
||||
averageRating: profile.averageRating,
|
||||
@@ -121,25 +112,28 @@ class StaffProfilePage extends StatelessWidget {
|
||||
noShowCount: profile.noShowCount,
|
||||
cancellationCount: profile.cancellationCount,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Reliability Score Bar
|
||||
ReliabilityScoreBar(
|
||||
reliabilityScore: profile.reliabilityScore,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Ordered sections
|
||||
const OnboardingSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Compliance section
|
||||
const ComplianceSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Finance section
|
||||
const FinanceSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Support section
|
||||
const SupportSection(),
|
||||
|
||||
// Logout button at the bottom
|
||||
const LogoutButton(),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
const SettingsSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
LogoutButton(
|
||||
onTap: () => _onSignOut(cubit, state),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space12),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -149,6 +143,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,20 +1,40 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/profile_cubit.dart';
|
||||
import '../blocs/profile_state.dart';
|
||||
|
||||
/// The sign-out button widget.
|
||||
///
|
||||
/// Uses design system tokens for all colors, typography, spacing, and icons.
|
||||
/// Handles logout logic when tapped and navigates to onboarding on success.
|
||||
class LogoutButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const LogoutButton({super.key});
|
||||
|
||||
const LogoutButton({super.key, required this.onTap});
|
||||
/// Handles the sign-out action.
|
||||
///
|
||||
/// Checks if the profile is not currently loading, then triggers the
|
||||
/// sign-out process via the ProfileCubit.
|
||||
void _handleSignOut(BuildContext context, ProfileState state) {
|
||||
if (state.status != ProfileStatus.loading) {
|
||||
context.read<ProfileCubit>().signOut();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.staff.profile.header;
|
||||
final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header;
|
||||
|
||||
return Container(
|
||||
return BlocListener<ProfileCubit, ProfileState>(
|
||||
listener: (BuildContext context, ProfileState state) {
|
||||
if (state.status == ProfileStatus.signedOut) {
|
||||
// Navigate to get started page after successful sign-out
|
||||
// This will be handled by the profile page listener
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
@@ -24,13 +44,18 @@ class LogoutButton extends StatelessWidget {
|
||||
child: Material(
|
||||
color: UiColors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
_handleSignOut(
|
||||
context,
|
||||
context.read<ProfileCubit>().state,
|
||||
);
|
||||
},
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.logOut,
|
||||
color: UiColors.destructive,
|
||||
@@ -46,6 +71,7 @@ class LogoutButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:flutter/material.dart';
|
||||
|
||||
/// An individual item within the profile menu grid.
|
||||
///
|
||||
/// Uses design system tokens for all colors, typography, spacing, and borders.
|
||||
class ProfileMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool? completed;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ProfileMenuItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
@@ -18,6 +13,11 @@ class ProfileMenuItem extends StatelessWidget {
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool? completed;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
@@ -32,12 +32,12 @@ class ProfileMenuItem extends StatelessWidget {
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -73,21 +73,22 @@ class ProfileMenuItem extends StatelessWidget {
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: completed! ? UiColors.primary : UiColors.error,
|
||||
width: 0.5,
|
||||
),
|
||||
color: completed!
|
||||
? UiColors.primary
|
||||
: UiColors.primary.withValues(alpha: 0.1),
|
||||
? UiColors.primary.withValues(alpha: 0.1)
|
||||
: UiColors.error.withValues(alpha: 0.15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: completed!
|
||||
? const Icon(
|
||||
UiIcons.check,
|
||||
size: 10,
|
||||
color: UiColors.primaryForeground,
|
||||
color: UiColors.primary,
|
||||
)
|
||||
: Text(
|
||||
"!",
|
||||
style: UiTypography.footnote2b.primary,
|
||||
),
|
||||
: Text("!", style: UiTypography.footnote2b.textError),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/profile_cubit.dart';
|
||||
import '../../blocs/profile_state.dart';
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
@@ -11,6 +14,7 @@ import '../section_title.dart';
|
||||
/// Widget displaying the compliance section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for tax forms and other compliance-related documents.
|
||||
/// Displays completion status for each item.
|
||||
class ComplianceSection extends StatelessWidget {
|
||||
/// Creates a [ComplianceSection].
|
||||
const ComplianceSection({super.key});
|
||||
@@ -19,6 +23,8 @@ class ComplianceSection extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
|
||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
@@ -29,11 +35,14 @@ class ComplianceSection extends StatelessWidget {
|
||||
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 'finance_section.dart';
|
||||
export 'onboarding_section.dart';
|
||||
export 'settings_section.dart';
|
||||
export 'support_section.dart';
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/profile_cubit.dart';
|
||||
import '../../blocs/profile_state.dart';
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
@@ -11,7 +14,7 @@ import '../section_title.dart';
|
||||
/// Widget displaying the onboarding section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for personal information, emergency contact,
|
||||
/// and work experience setup.
|
||||
/// and work experience setup. Displays completion status for each item.
|
||||
class OnboardingSection extends StatelessWidget {
|
||||
/// Creates an [OnboardingSection].
|
||||
const OnboardingSection({super.key});
|
||||
@@ -20,6 +23,8 @@ class OnboardingSection extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
|
||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.onboarding),
|
||||
@@ -29,21 +34,26 @@ class OnboardingSection extends StatelessWidget {
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.user,
|
||||
label: i18n.menu_items.personal_info,
|
||||
completed: state.personalInfoComplete,
|
||||
onTap: () => Modular.to.toPersonalInfo(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.phone,
|
||||
label: i18n.menu_items.emergency_contact,
|
||||
completed: state.emergencyContactsComplete,
|
||||
onTap: () => Modular.to.toEmergencyContact(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.briefcase,
|
||||
label: i18n.menu_items.experience,
|
||||
completed: state.experienceComplete,
|
||||
onTap: () => Modular.to.toExperience(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
import 'data/repositories/profile_repository_impl.dart';
|
||||
import 'domain/repositories/profile_repository.dart';
|
||||
import 'domain/usecases/get_profile_usecase.dart';
|
||||
import 'domain/usecases/sign_out_usecase.dart';
|
||||
import 'presentation/blocs/profile_cubit.dart';
|
||||
import 'presentation/pages/staff_profile_page.dart';
|
||||
|
||||
@@ -15,28 +12,56 @@ import 'presentation/pages/staff_profile_page.dart';
|
||||
/// following Clean Architecture principles.
|
||||
///
|
||||
/// Dependency flow:
|
||||
/// - Repository implementation (ProfileRepositoryImpl) delegates to data_connect
|
||||
/// - Use cases depend on repository interface
|
||||
/// - Use cases from data_connect layer (StaffConnectorRepository)
|
||||
/// - Cubit depends on use cases
|
||||
class StaffProfileModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository implementation - delegates to data_connect
|
||||
i.addLazySingleton<ProfileRepositoryInterface>(
|
||||
ProfileRepositoryImpl.new,
|
||||
// StaffConnectorRepository intialization
|
||||
i.addLazySingleton<StaffConnectorRepository>(
|
||||
() => StaffConnectorRepositoryImpl(),
|
||||
);
|
||||
|
||||
// Use cases - depend on repository interface
|
||||
i.addLazySingleton<GetProfileUseCase>(
|
||||
() => GetProfileUseCase(i.get<ProfileRepositoryInterface>()),
|
||||
// Use cases from data_connect - depend on StaffConnectorRepository
|
||||
i.addLazySingleton<GetStaffProfileUseCase>(
|
||||
() =>
|
||||
GetStaffProfileUseCase(repository: i.get<StaffConnectorRepository>()),
|
||||
);
|
||||
i.addLazySingleton<SignOutUseCase>(
|
||||
() => SignOutUseCase(i.get<ProfileRepositoryInterface>()),
|
||||
i.addLazySingleton<SignOutStaffUseCase>(
|
||||
() => 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
|
||||
i.add<ProfileCubit>(
|
||||
() => ProfileCubit(i.get<GetProfileUseCase>(), i.get<SignOutUseCase>()),
|
||||
// Presentation layer - Cubit as singleton to avoid recreation
|
||||
// BlocProvider will use this same instance, preventing state emission after close
|
||||
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:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
|
||||
/// A form widget containing all personal information fields.
|
||||
@@ -77,11 +79,73 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
hint: i18n.locations_hint,
|
||||
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.
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'data/repositories/personal_info_repository_impl.dart';
|
||||
import 'domain/repositories/personal_info_repository_interface.dart';
|
||||
@@ -7,6 +8,7 @@ import 'domain/usecases/get_personal_info_usecase.dart';
|
||||
import 'domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'presentation/blocs/personal_info_bloc.dart';
|
||||
import 'presentation/pages/personal_info_page.dart';
|
||||
import 'presentation/pages/language_selection_page.dart';
|
||||
|
||||
/// The entry module for the Staff Profile Info feature.
|
||||
///
|
||||
@@ -23,7 +25,8 @@ class StaffProfileInfoModule extends Module {
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<PersonalInfoRepositoryInterface>(
|
||||
PersonalInfoRepositoryImpl.new);
|
||||
PersonalInfoRepositoryImpl.new,
|
||||
);
|
||||
|
||||
// Use Cases - delegate business logic to repository
|
||||
i.addLazySingleton<GetPersonalInfoUseCase>(
|
||||
@@ -45,13 +48,18 @@ class StaffProfileInfoModule extends Module {
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(
|
||||
'/personal-info',
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.onboardingPersonalInfo,
|
||||
StaffPaths.onboardingPersonalInfo,
|
||||
),
|
||||
child: (BuildContext context) => const PersonalInfoPage(),
|
||||
);
|
||||
// Alias with trailing slash to be tolerant of external deep links
|
||||
r.child(
|
||||
'/personal-info/',
|
||||
child: (BuildContext context) => const PersonalInfoPage(),
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.onboardingPersonalInfo,
|
||||
StaffPaths.languageSelection,
|
||||
),
|
||||
child: (BuildContext context) => const LanguageSelectionPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
@@ -22,6 +23,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
final GetPendingAssignmentsUseCase getPendingAssignments;
|
||||
final GetCancelledShiftsUseCase getCancelledShifts;
|
||||
final GetHistoryShiftsUseCase getHistoryShifts;
|
||||
final GetProfileCompletionUseCase getProfileCompletion;
|
||||
|
||||
ShiftsBloc({
|
||||
required this.getMyShifts,
|
||||
@@ -29,6 +31,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
required this.getPendingAssignments,
|
||||
required this.getCancelledShifts,
|
||||
required this.getHistoryShifts,
|
||||
required this.getProfileCompletion,
|
||||
}) : super(ShiftsInitial()) {
|
||||
on<LoadShiftsEvent>(_onLoadShifts);
|
||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||
@@ -36,6 +39,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
on<LoadFindFirstEvent>(_onLoadFindFirst);
|
||||
on<LoadShiftsForRangeEvent>(_onLoadShiftsForRange);
|
||||
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
|
||||
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
|
||||
}
|
||||
|
||||
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) {
|
||||
final now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
|
||||
@@ -54,3 +54,10 @@ class DeclineShiftEvent extends ShiftsEvent {
|
||||
@override
|
||||
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 String searchQuery;
|
||||
final String jobType;
|
||||
final bool? profileComplete;
|
||||
|
||||
const ShiftsLoaded({
|
||||
required this.myShifts,
|
||||
@@ -39,6 +40,7 @@ class ShiftsLoaded extends ShiftsState {
|
||||
required this.myShiftsLoaded,
|
||||
required this.searchQuery,
|
||||
required this.jobType,
|
||||
this.profileComplete,
|
||||
});
|
||||
|
||||
ShiftsLoaded copyWith({
|
||||
@@ -54,6 +56,7 @@ class ShiftsLoaded extends ShiftsState {
|
||||
bool? myShiftsLoaded,
|
||||
String? searchQuery,
|
||||
String? jobType,
|
||||
bool? profileComplete,
|
||||
}) {
|
||||
return ShiftsLoaded(
|
||||
myShifts: myShifts ?? this.myShifts,
|
||||
@@ -68,6 +71,7 @@ class ShiftsLoaded extends ShiftsState {
|
||||
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
jobType: jobType ?? this.jobType,
|
||||
profileComplete: profileComplete ?? this.profileComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@ class ShiftsLoaded extends ShiftsState {
|
||||
myShiftsLoaded,
|
||||
searchQuery,
|
||||
jobType,
|
||||
profileComplete ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
}
|
||||
}
|
||||
// Check profile completion
|
||||
_bloc.add(const CheckProfileCompletionEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -138,15 +140,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
// Tabs
|
||||
Row(
|
||||
children: [
|
||||
_buildTab(
|
||||
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
"myshifts",
|
||||
t.staff_shifts.tabs.my_shifts,
|
||||
UiIcons.calendar,
|
||||
myShifts.length,
|
||||
showCount: myShiftsLoaded,
|
||||
enabled: !blockTabsForFind,
|
||||
enabled: !blockTabsForFind && (state.profileComplete ?? false),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
_buildTab(
|
||||
"find",
|
||||
t.staff_shifts.tabs.find_work,
|
||||
@@ -155,15 +165,25 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
showCount: availableLoaded,
|
||||
enabled: baseLoaded,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildTab(
|
||||
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state is ShiftsLoaded && state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
"history",
|
||||
t.staff_shifts.tabs.history,
|
||||
UiIcons.clock,
|
||||
historyShifts.length,
|
||||
showCount: historyLoaded,
|
||||
enabled: !blockTabsForFind && baseLoaded,
|
||||
enabled: !blockTabsForFind &&
|
||||
baseLoaded &&
|
||||
(state.profileComplete ?? false),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'domain/repositories/shifts_repository_interface.dart';
|
||||
import 'data/repositories_impl/shifts_repository_impl.dart';
|
||||
import 'domain/usecases/get_my_shifts_usecase.dart';
|
||||
@@ -17,6 +18,18 @@ import 'presentation/pages/shifts_page.dart';
|
||||
class StaffShiftsModule extends Module {
|
||||
@override
|
||||
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
|
||||
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
|
||||
|
||||
@@ -32,7 +45,14 @@ class StaffShiftsModule extends Module {
|
||||
i.add(GetShiftDetailsUseCase.new);
|
||||
|
||||
// Bloc
|
||||
i.add(ShiftsBloc.new);
|
||||
i.add(() => ShiftsBloc(
|
||||
getMyShifts: i.get(),
|
||||
getAvailableShifts: i.get(),
|
||||
getPendingAssignments: i.get(),
|
||||
getCancelledShifts: i.get(),
|
||||
getHistoryShifts: i.get(),
|
||||
getProfileCompletion: i.get(),
|
||||
));
|
||||
i.add(ShiftDetailsBloc.new);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
|
||||
|
||||
class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
||||
StaffMainCubit() : super(const StaffMainState()) {
|
||||
StaffMainCubit({
|
||||
required GetProfileCompletionUseCase getProfileCompletionUsecase,
|
||||
}) : _getProfileCompletionUsecase = getProfileCompletionUsecase,
|
||||
super(const StaffMainState()) {
|
||||
Modular.to.addListener(_onRouteChanged);
|
||||
_onRouteChanged();
|
||||
_loadProfileCompletion();
|
||||
}
|
||||
|
||||
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
|
||||
|
||||
void _onRouteChanged() {
|
||||
if (isClosed) return;
|
||||
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) {
|
||||
if (index == state.currentIndex) return;
|
||||
|
||||
|
||||
@@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart';
|
||||
class StaffMainState extends Equatable {
|
||||
const StaffMainState({
|
||||
this.currentIndex = 2, // Default to Home
|
||||
this.isProfileComplete = false,
|
||||
});
|
||||
|
||||
final int currentIndex;
|
||||
final bool isProfileComplete;
|
||||
|
||||
StaffMainState copyWith({int? currentIndex}) {
|
||||
return StaffMainState(currentIndex: currentIndex ?? this.currentIndex);
|
||||
StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) {
|
||||
return StaffMainState(
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<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 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
|
||||
import 'package:staff_main/src/utils/index.dart';
|
||||
|
||||
/// A custom bottom navigation bar for the Staff app.
|
||||
///
|
||||
@@ -10,6 +13,10 @@ import 'package:flutter/material.dart';
|
||||
/// and follows the KROW Design System guidelines. It displays five tabs:
|
||||
/// Shifts, Payments, Home, Clock In, and Profile.
|
||||
///
|
||||
/// Navigation items are gated by profile completion status. Items marked with
|
||||
/// [StaffNavItem.requireProfileCompletion] are only visible when the profile
|
||||
/// is complete.
|
||||
///
|
||||
/// The widget uses:
|
||||
/// - [UiColors] for all color values
|
||||
/// - [UiTypography] for text styling
|
||||
@@ -36,12 +43,15 @@ class StaffMainBottomBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Translations.of(context);
|
||||
// Staff App colors from design system
|
||||
// Using primary (Blue) for active as per prototype
|
||||
const Color activeColor = UiColors.primary;
|
||||
const Color inactiveColor = UiColors.textInactive;
|
||||
|
||||
return BlocBuilder<StaffMainCubit, StaffMainState>(
|
||||
builder: (BuildContext context, StaffMainState state) {
|
||||
final bool isProfileComplete = state.isProfileComplete;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
@@ -73,46 +83,21 @@ class StaffMainBottomBar extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
_buildNavItem(
|
||||
index: 0,
|
||||
icon: UiIcons.briefcase,
|
||||
label: t.staff.main.tabs.shifts,
|
||||
...defaultStaffNavItems.map(
|
||||
(item) => _buildNavItem(
|
||||
item: item,
|
||||
activeColor: activeColor,
|
||||
inactiveColor: inactiveColor,
|
||||
isProfileComplete: isProfileComplete,
|
||||
),
|
||||
_buildNavItem(
|
||||
index: 1,
|
||||
icon: UiIcons.dollar,
|
||||
label: t.staff.main.tabs.payments,
|
||||
activeColor: activeColor,
|
||||
inactiveColor: inactiveColor,
|
||||
),
|
||||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single navigation item.
|
||||
@@ -122,30 +107,37 @@ class StaffMainBottomBar extends StatelessWidget {
|
||||
/// - Spacing uses [UiConstants.space1]
|
||||
/// - Typography uses [UiTypography.footnote2m]
|
||||
/// - Colors are passed as parameters from design system
|
||||
///
|
||||
/// Items with [item.requireProfileCompletion] = true are hidden when
|
||||
/// [isProfileComplete] is false.
|
||||
Widget _buildNavItem({
|
||||
required int index,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required StaffNavItem item,
|
||||
required Color activeColor,
|
||||
required Color inactiveColor,
|
||||
required bool isProfileComplete,
|
||||
}) {
|
||||
final bool isSelected = currentIndex == index;
|
||||
// Hide item if profile completion is required but not complete
|
||||
if (item.requireProfileCompletion && !isProfileComplete) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final bool isSelected = currentIndex == item.index;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(index),
|
||||
onTap: () => onTap(item.index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
item.icon,
|
||||
color: isSelected ? activeColor : inactiveColor,
|
||||
size: UiConstants.iconLg,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
label,
|
||||
item.label,
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: isSelected ? activeColor : inactiveColor,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:staff_attire/staff_attire.dart';
|
||||
import 'package:staff_availability/staff_availability.dart';
|
||||
import 'package:staff_bank_account/staff_bank_account.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:staff_certificates/staff_certificates.dart';
|
||||
import 'package:staff_clock_in/staff_clock_in.dart';
|
||||
import 'package:staff_documents/staff_documents.dart';
|
||||
import 'package:staff_emergency_contact/staff_emergency_contact.dart';
|
||||
import 'package:staff_faqs/staff_faqs.dart';
|
||||
import 'package:staff_home/staff_home.dart';
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||
import 'package:staff_main/src/presentation/pages/staff_main_page.dart';
|
||||
@@ -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_shifts/staff_shifts.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';
|
||||
|
||||
class StaffMainModule extends Module {
|
||||
@override
|
||||
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
|
||||
@@ -56,7 +73,7 @@ class StaffMainModule extends Module {
|
||||
],
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo).replaceFirst('/personal-info', ''),
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo),
|
||||
module: StaffProfileInfoModule(),
|
||||
);
|
||||
r.module(
|
||||
|
||||
@@ -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:
|
||||
path: ../../../core_localization
|
||||
krow_core:
|
||||
path: ../../../krow_core
|
||||
path: ../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
# Features
|
||||
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 `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only.
|
||||
* **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations.
|
||||
* **Dependency Injection**: Use Flutter Modular for BLoC 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
|
||||
|
||||
|
||||
@@ -64,11 +64,11 @@ graph TD
|
||||
### 2.2 Features (`apps/mobile/packages/features/<APP_NAME>/<FEATURE_NAME>`)
|
||||
- **Role**: Vertical slices of user-facing functionality.
|
||||
- **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.
|
||||
- `presentation/`:
|
||||
- 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**:
|
||||
- **Presentation**: UI Pages, Modular Routes.
|
||||
- **State Management**: BLoCs / Cubits.
|
||||
@@ -85,10 +85,18 @@ graph TD
|
||||
### 2.4 Data Connect (`apps/mobile/packages/data_connect`)
|
||||
- **Role**: Interface Adapter for Backend Access (Datasource Layer).
|
||||
- **Responsibilities**:
|
||||
- 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.
|
||||
- **Connectors**: Centralized repository implementations for each backend connector (see `03-data-connect-connectors-pattern.md`)
|
||||
- One connector per backend connector domain (staff, order, user, etc.)
|
||||
- Repository interfaces and use cases defined at domain level
|
||||
- Repository implementations query backend and map responses
|
||||
- Implement Firebase Data Connect connector and service layer
|
||||
- Map Domain Entities to/from Data Connect generated code
|
||||
- Handle Firebase exceptions and map to domain failures
|
||||
- Provide centralized `DataConnectService` with session management
|
||||
- **RESTRICTION**:
|
||||
- NO feature-specific logic. Connectors are domain-neutral and reusable.
|
||||
- All queries must follow Clean Architecture (domain → data layers)
|
||||
- See `03-data-connect-connectors-pattern.md` for detailed pattern documentation
|
||||
|
||||
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
||||
- **Role**: Visual language and component library.
|
||||
@@ -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?)`
|
||||
- **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null
|
||||
- **Navigation**: On auth → `Modular.to.toClientHome()`, on unauth → `Modular.to.toInitialPage()`
|
||||
|
||||
## 7. Data Connect Connectors Pattern
|
||||
|
||||
See **`03-data-connect-connectors-pattern.md`** for comprehensive documentation on:
|
||||
- How connector repositories work
|
||||
- How to add queries to existing connectors
|
||||
- How to create new connectors
|
||||
- Integration patterns with features
|
||||
- Benefits and anti-patterns
|
||||
|
||||
**Quick Reference**:
|
||||
- All backend queries centralized in `apps/mobile/packages/data_connect/lib/src/connectors/`
|
||||
- One connector per backend connector domain (staff, order, user, etc.)
|
||||
- Each connector follows Clean Architecture (domain interfaces + data implementations)
|
||||
- Features use connector repositories through dependency injection
|
||||
- Results in zero query duplication and single source of truth
|
||||
|
||||
## 8. Prop Drilling Prevention & Direct BLoC Access
|
||||
|
||||
### 8.1 The Problem: Prop Drilling
|
||||
|
||||
Passing data through intermediate widgets creates maintenance headaches:
|
||||
- Every intermediate widget must accept and forward props
|
||||
- Changes to data structure ripple through multiple widget constructors
|
||||
- Reduces code clarity and increases cognitive load
|
||||
|
||||
**Anti-Pattern Example**:
|
||||
```dart
|
||||
// ❌ BAD: Drilling status through 3 levels
|
||||
ProfilePage(status: status)
|
||||
→ ProfileHeader(status: status)
|
||||
→ ProfileLevelBadge(status: status) // Only widget that needs it!
|
||||
```
|
||||
|
||||
### 8.2 The Solution: Direct BLoC Access with BlocBuilder
|
||||
|
||||
Use `BlocBuilder` to access BLoC state directly in leaf widgets:
|
||||
|
||||
**Correct Pattern**:
|
||||
```dart
|
||||
// ✅ GOOD: ProfileLevelBadge accesses ProfileCubit directly
|
||||
class ProfileLevelBadge extends StatelessWidget {
|
||||
const ProfileLevelBadge({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<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