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:
Achintha Isuru
2026-02-19 16:28:57 -05:00
committed by GitHub
61 changed files with 2226 additions and 1173 deletions

237
.github/workflows/mobile-ci.yml vendored Normal file
View 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

View File

@@ -16,14 +16,14 @@ class StaffPaths {
/// Generate child route based on the given route and parent route
///
/// This is useful for creating nested routes within modules.
static String childRoute(String parent, String child) {
static String childRoute(String parent, String child) {
final String childPath = child.replaceFirst(parent, '');
// check if the child path is empty
if (childPath.isEmpty) {
return '/';
}
}
// ensure the child path starts with a '/'
if (!childPath.startsWith('/')) {
return '/$childPath';
@@ -31,7 +31,7 @@ class StaffPaths {
return childPath;
}
// ==========================================================================
// AUTHENTICATION
// ==========================================================================
@@ -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.
///

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving emergency contacts completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one emergency contact registered.
/// It delegates to the repository for data access.
class GetEmergencyContactsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetEmergencyContactsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetEmergencyContactsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get emergency contacts completion status.
///
/// Returns true if emergency contacts are registered, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getEmergencyContactsCompletion();
}

View File

@@ -0,0 +1,27 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving experience completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has experience data (skills or industries) defined.
/// It delegates to the repository for data access.
class GetExperienceCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetExperienceCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetExperienceCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get experience completion status.
///
/// Returns true if experience data is defined, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getExperienceCompletion();
}

View File

@@ -0,0 +1,27 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving personal information completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's personal information is complete (name, email, phone).
/// It delegates to the repository for data access.
class GetPersonalInfoCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetPersonalInfoCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetPersonalInfoCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get personal info completion status.
///
/// Returns true if personal information is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getPersonalInfoCompletion();
}

View File

@@ -0,0 +1,27 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving staff profile completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's profile is complete. It delegates to the repository
/// for data access.
class GetProfileCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetProfileCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetProfileCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get profile completion status.
///
/// Returns true if the profile is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getProfileCompletion();
}

View File

@@ -0,0 +1,28 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for fetching a staff member's full profile information.
///
/// This use case encapsulates the business logic for retrieving the complete
/// staff profile including personal info, ratings, and reliability scores.
/// It delegates to the repository for data access.
class GetStaffProfileUseCase extends UseCase<void, Staff> {
/// Creates a [GetStaffProfileUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffProfileUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get the staff profile.
///
/// Returns a [Staff] entity containing all profile information.
///
/// Throws an exception if the operation fails.
@override
Future<Staff> call([void params]) => _repository.getStaffProfile();
}

View File

@@ -0,0 +1,27 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving tax forms completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one tax form submitted.
/// It delegates to the repository for data access.
class GetTaxFormsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetTaxFormsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetTaxFormsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get tax forms completion status.
///
/// Returns true if tax forms are submitted, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getTaxFormsCompletion();
}

View File

@@ -0,0 +1,25 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for signing out the current staff user.
///
/// This use case encapsulates the business logic for signing out,
/// including clearing authentication state and cache.
/// It delegates to the repository for data access.
class SignOutStaffUseCase extends NoInputUseCase<void> {
/// Creates a [SignOutStaffUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
SignOutStaffUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to sign out the user.
///
/// Throws an exception if the operation fails.
@override
Future<void> call() => _repository.signOut();
}

View File

@@ -13,8 +13,9 @@ dependencies:
sdk: flutter
krow_domain:
path: ../domain
krow_core:
path: ../core
flutter_modular: ^6.3.0
firebase_data_connect: ^0.2.2+2
firebase_core: ^4.4.0
firebase_auth: ^6.1.4
krow_core: ^0.0.1

View File

@@ -5,17 +5,14 @@ publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
sdk: ">=3.10.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
# Architecture Packages
design_system:
path: ../../../design_system
core_localization:
@@ -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:

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
return;
}
Modular.to.navigate(
'/client-main/orders/',
ClientPaths.orders,
arguments: <String, dynamic>{
'initialDate': initialDate.toIso8601String(),
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,11 +32,27 @@ 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,
];
}

View File

@@ -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,129 +25,124 @@ class StaffProfilePage extends StatelessWidget {
/// Creates a [StaffProfilePage].
const StaffProfilePage({super.key});
String _mapStatusToLevel(StaffStatus status) {
switch (status) {
case StaffStatus.active:
case StaffStatus.verified:
return 'Krower I';
case StaffStatus.pending:
case StaffStatus.completedProfile:
return 'Pending';
default:
return 'New';
}
}
void _onSignOut(ProfileCubit cubit, ProfileState state) {
if (state.status != ProfileStatus.loading) {
cubit.signOut();
}
}
@override
Widget build(BuildContext context) {
final 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,
listener: (BuildContext context, ProfileState state) {
if (state.status == ProfileStatus.signedOut) {
Modular.to.toGetStartedPage();
} else if (state.status == ProfileStatus.error &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ProfileState state) {
// Show loading spinner if status is loading
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 &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ProfileState state) {
// Show loading spinner if status is loading
if (state.status == ProfileStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == ProfileStatus.error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
textAlign: TextAlign.center,
style: UiTypography.body1r.copyWith(
color: UiColors.textSecondary,
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
textAlign: TextAlign.center,
style: UiTypography.body1r.copyWith(
color: UiColors.textSecondary,
),
),
),
);
}
final Staff? profile = state.profile;
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: UiConstants.space16),
child: Column(
children: <Widget>[
ProfileHeader(
fullName: profile.name,
photoUrl: profile.avatar,
),
Transform.translate(
offset: const Offset(0, -UiConstants.space6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
spacing: UiConstants.space6,
children: <Widget>[
// Reliability Stats and Score
ReliabilityStatsCard(
totalShifts: profile.totalShifts,
averageRating: profile.averageRating,
onTimeRate: profile.onTimeRate,
noShowCount: profile.noShowCount,
cancellationCount: profile.cancellationCount,
),
// Reliability Score Bar
ReliabilityScoreBar(
reliabilityScore: profile.reliabilityScore,
),
// Ordered sections
const OnboardingSection(),
// Compliance section
const ComplianceSection(),
// Finance section
const FinanceSection(),
// Support section
const SupportSection(),
// Logout button at the bottom
const LogoutButton(),
const SizedBox(height: UiConstants.space6),
],
),
),
),
],
),
);
}
final Staff? profile = state.profile;
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: UiConstants.space16),
child: Column(
children: <Widget>[
ProfileHeader(
fullName: profile.name,
level: _mapStatusToLevel(profile.status),
photoUrl: profile.avatar,
onSignOutTap: () => _onSignOut(cubit, state),
),
Transform.translate(
offset: const Offset(0, -UiConstants.space6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
children: <Widget>[
ReliabilityStatsCard(
totalShifts: profile.totalShifts,
averageRating: profile.averageRating,
onTimeRate: profile.onTimeRate,
noShowCount: profile.noShowCount,
cancellationCount: profile.cancellationCount,
),
const SizedBox(height: UiConstants.space6),
ReliabilityScoreBar(
reliabilityScore: profile.reliabilityScore,
),
const SizedBox(height: UiConstants.space6),
const OnboardingSection(),
const SizedBox(height: UiConstants.space6),
const ComplianceSection(),
const SizedBox(height: UiConstants.space6),
const FinanceSection(),
const SizedBox(height: UiConstants.space6),
const SupportSection(),
const SizedBox(height: UiConstants.space6),
const SettingsSection(),
const SizedBox(height: UiConstants.space6),
LogoutButton(
onTap: () => _onSignOut(cubit, state),
),
const SizedBox(height: UiConstants.space12),
],
),
),
),
],
),
);
},
},
),
),
);
}

View File

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

View File

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

View File

@@ -1,47 +1,73 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/profile_cubit.dart';
import '../blocs/profile_state.dart';
/// The sign-out button widget.
///
/// Uses design system tokens for all colors, typography, spacing, and icons.
/// Handles logout logic when tapped and navigates to onboarding on success.
class LogoutButton extends StatelessWidget {
final VoidCallback onTap;
const LogoutButton({super.key});
const LogoutButton({super.key, required this.onTap});
/// Handles the sign-out action.
///
/// Checks if the profile is not currently loading, then triggers the
/// sign-out process via the ProfileCubit.
void _handleSignOut(BuildContext context, ProfileState state) {
if (state.status != ProfileStatus.loading) {
context.read<ProfileCubit>().signOut();
}
}
@override
Widget build(BuildContext context) {
final i18n = t.staff.profile.header;
final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Material(
color: UiColors.transparent,
child: InkWell(
onTap: onTap,
return BlocListener<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,
borderRadius: UiConstants.radiusLg,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
UiIcons.logOut,
color: UiColors.destructive,
size: 20,
),
const SizedBox(width: UiConstants.space2),
Text(
i18n.sign_out,
style: UiTypography.body1m.textError,
),
],
border: Border.all(color: UiColors.border),
),
child: Material(
color: UiColors.transparent,
child: InkWell(
onTap: () {
_handleSignOut(
context,
context.read<ProfileCubit>().state,
);
},
borderRadius: UiConstants.radiusLg,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.logOut,
color: UiColors.destructive,
size: 20,
),
const SizedBox(width: UiConstants.space2),
Text(
i18n.sign_out,
style: UiTypography.body1m.textError,
),
],
),
),
),
),

View File

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

View File

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

View File

@@ -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,21 +23,26 @@ class ComplianceSection extends StatelessWidget {
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SectionTitle(i18n.sections.compliance),
ProfileMenuGrid(
crossAxisCount: 3,
return BlocBuilder<ProfileCubit, ProfileState>(
builder: (BuildContext context, ProfileState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.file,
label: i18n.menu_items.tax_forms,
onTap: () => Modular.to.toTaxForms(),
SectionTitle(i18n.sections.compliance),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.file,
label: i18n.menu_items.tax_forms,
completed: state.taxFormsComplete,
onTap: () => Modular.to.toTaxForms(),
),
],
),
],
),
],
);
},
);
}
}

View File

@@ -1,5 +1,5 @@
export 'compliance_section.dart';
export 'finance_section.dart';
export 'onboarding_section.dart';
export 'settings_section.dart';
export 'support_section.dart';

View File

@@ -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,30 +23,37 @@ class OnboardingSection extends StatelessWidget {
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
return Column(
children: <Widget>[
SectionTitle(i18n.sections.onboarding),
ProfileMenuGrid(
crossAxisCount: 3,
return BlocBuilder<ProfileCubit, ProfileState>(
builder: (BuildContext context, ProfileState state) {
return Column(
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.user,
label: i18n.menu_items.personal_info,
onTap: () => Modular.to.toPersonalInfo(),
),
ProfileMenuItem(
icon: UiIcons.phone,
label: i18n.menu_items.emergency_contact,
onTap: () => Modular.to.toEmergencyContact(),
),
ProfileMenuItem(
icon: UiIcons.briefcase,
label: i18n.menu_items.experience,
onTap: () => Modular.to.toExperience(),
SectionTitle(i18n.sections.onboarding),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
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(),
),
],
),
],
),
],
);
},
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => [];
}

View File

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

View File

@@ -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(
"myshifts",
t.staff_shifts.tabs.my_shifts,
UiIcons.calendar,
myShifts.length,
showCount: myShiftsLoaded,
enabled: !blockTabsForFind,
),
const SizedBox(width: UiConstants.space2),
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 && (state.profileComplete ?? false),
),
)
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(
"history",
t.staff_shifts.tabs.history,
UiIcons.clock,
historyShifts.length,
showCount: historyLoaded,
enabled: !blockTabsForFind && baseLoaded,
),
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 &&
(state.profileComplete ?? false),
),
)
else
const SizedBox.shrink(),
],
),
],

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import 'dart:ui';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
import 'package:staff_main/src/presentation/blocs/staff_main_state.dart';
import 'package:staff_main/src/utils/index.dart';
/// A custom bottom navigation bar for the Staff app.
///
@@ -10,6 +13,10 @@ import 'package:flutter/material.dart';
/// and follows the KROW Design System guidelines. It displays five tabs:
/// Shifts, Payments, Home, Clock In, and Profile.
///
/// Navigation items are gated by profile completion status. Items marked with
/// [StaffNavItem.requireProfileCompletion] are only visible when the profile
/// is complete.
///
/// The widget uses:
/// - [UiColors] for all color values
/// - [UiTypography] for text styling
@@ -36,82 +43,60 @@ class StaffMainBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
// Staff App colors from design system
// Using primary (Blue) for active as per prototype
const Color activeColor = UiColors.primary;
const Color inactiveColor = UiColors.textInactive;
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
// Glassmorphic background with blur effect
Positioned.fill(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.85),
border: Border(
top: BorderSide(
color: UiColors.black.withValues(alpha: 0.1),
return BlocBuilder<StaffMainCubit, StaffMainState>(
builder: (BuildContext context, StaffMainState state) {
final bool isProfileComplete = state.isProfileComplete;
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
// Glassmorphic background with blur effect
Positioned.fill(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.85),
border: Border(
top: BorderSide(
color: UiColors.black.withValues(alpha: 0.1),
),
),
),
),
),
),
),
),
),
// Navigation items
Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2,
top: UiConstants.space4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_buildNavItem(
index: 0,
icon: UiIcons.briefcase,
label: t.staff.main.tabs.shifts,
activeColor: activeColor,
inactiveColor: inactiveColor,
// Navigation items
Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2,
top: UiConstants.space4,
),
_buildNavItem(
index: 1,
icon: UiIcons.dollar,
label: t.staff.main.tabs.payments,
activeColor: activeColor,
inactiveColor: inactiveColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
...defaultStaffNavItems.map(
(item) => _buildNavItem(
item: item,
activeColor: activeColor,
inactiveColor: inactiveColor,
isProfileComplete: isProfileComplete,
),
),
],
),
_buildNavItem(
index: 2,
icon: UiIcons.home,
label: t.staff.main.tabs.home,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 3,
icon: UiIcons.clock,
label: t.staff.main.tabs.clock_in,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 4,
icon: UiIcons.users,
label: t.staff.main.tabs.profile,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
],
),
),
],
),
],
);
},
);
}
@@ -122,30 +107,37 @@ class StaffMainBottomBar extends StatelessWidget {
/// - Spacing uses [UiConstants.space1]
/// - Typography uses [UiTypography.footnote2m]
/// - Colors are passed as parameters from design system
///
/// Items with [item.requireProfileCompletion] = true are hidden when
/// [isProfileComplete] is false.
Widget _buildNavItem({
required int index,
required IconData icon,
required String label,
required StaffNavItem item,
required Color activeColor,
required Color inactiveColor,
required bool isProfileComplete,
}) {
final bool isSelected = currentIndex == index;
// Hide item if profile completion is required but not complete
if (item.requireProfileCompletion && !isProfileComplete) {
return const SizedBox.shrink();
}
final bool isSelected = currentIndex == item.index;
return Expanded(
child: GestureDetector(
onTap: () => onTap(index),
onTap: () => onTap(item.index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: <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,
),

View File

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

View File

@@ -0,0 +1,2 @@
export 'staff_nav_item.dart';
export 'staff_nav_items_config.dart';

View File

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

View File

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

View File

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

View File

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

View File

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

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