diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 04b25374..202bc20b 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } android { - namespace = "com.example.krow_client" + namespace = "com.krowwithus.client" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion diff --git a/apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt b/apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt index 419b3bd4..3e393b5d 100644 --- a/apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt +++ b/apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.krow_client +package com.krowwithus.client import io.flutter.embedding.android.FlutterActivity diff --git a/apps/mobile/apps/client/linux/CMakeLists.txt b/apps/mobile/apps/client/linux/CMakeLists.txt index 6f1df0fe..350d88d7 100644 --- a/apps/mobile/apps/client/linux/CMakeLists.txt +++ b/apps/mobile/apps/client/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "krow_client") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.krow_client") +set(APPLICATION_ID "com.krowwithus.client") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 80f2b222..8764d57b 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } android { - namespace = "com.example.krow_staff" + namespace = "com.krowwithus.staff" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion diff --git a/apps/mobile/apps/staff/android/app/google-services.json b/apps/mobile/apps/staff/android/app/google-services.json index 13b4592b..42bb1f02 100644 --- a/apps/mobile/apps/staff/android/app/google-services.json +++ b/apps/mobile/apps/staff/android/app/google-services.json @@ -193,6 +193,14 @@ } }, "oauth_client": [ + { + "client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.krowwithus.staff", + "certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" + } + }, { "client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com", "client_type": 3 diff --git a/apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt b/apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt index 13520833..b892977d 100644 --- a/apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt +++ b/apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.krow_staff +package com.krowwithus.staff import io.flutter.embedding.android.FlutterActivity diff --git a/apps/mobile/apps/staff/linux/CMakeLists.txt b/apps/mobile/apps/staff/linux/CMakeLists.txt index b222a83e..56ce18bd 100644 --- a/apps/mobile/apps/staff/linux/CMakeLists.txt +++ b/apps/mobile/apps/staff/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "krow_staff") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.krow_staff") +set(APPLICATION_ID "com.krowwithus.staff") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index f46af624..c6536ad4 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -2,3 +2,4 @@ library core; export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; +export 'src/utils/date_time_utils.dart'; diff --git a/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart new file mode 100644 index 00000000..1d142b33 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart @@ -0,0 +1,7 @@ + +class DateTimeUtils { + /// Converts a [DateTime] (assumed UTC if not specified) to the device's local time. + static DateTime toDeviceTime(DateTime date) { + return date.toLocal(); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 4998c45b..2c6081ed 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -24,6 +24,8 @@ class Shift extends Equatable { final double? longitude; final String? status; final int? durationDays; // For multi-day shifts + final int? requiredSlots; + final int? filledSlots; const Shift({ required this.id, @@ -49,6 +51,8 @@ class Shift extends Equatable { this.longitude, this.status, this.durationDays, + this.requiredSlots, + this.filledSlots, }); @override @@ -76,6 +80,8 @@ class Shift extends Equatable { longitude, status, durationDays, + requiredSlots, + filledSlots, ]; } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 43674a96..87146306 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -3,7 +3,6 @@ import 'dart:developer' as developer; import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' show InvalidCredentialsException, @@ -15,6 +14,7 @@ import 'package:krow_domain/krow_domain.dart' UnauthorizedAppException, PasswordMismatchException, GoogleOnlyAccountException; +import 'package:krow_domain/krow_domain.dart' as domain; import '../../domain/repositories/auth_repository_interface.dart'; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart new file mode 100644 index 00000000..0903fa83 --- /dev/null +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -0,0 +1,67 @@ +import 'package:firebase_auth/firebase_auth.dart' as auth; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/profile_setup_repository.dart'; + +class ProfileSetupRepositoryImpl implements ProfileSetupRepository { + final auth.FirebaseAuth _firebaseAuth; + final ExampleConnector _dataConnect; + + ProfileSetupRepositoryImpl({ + required auth.FirebaseAuth firebaseAuth, + required ExampleConnector dataConnect, + }) : _firebaseAuth = firebaseAuth, + _dataConnect = dataConnect; + + @override + Future submitProfile({ + required String fullName, + String? bio, + required List preferredLocations, + required double maxDistanceMiles, + required List industries, + required List skills, + }) async { + final auth.User? firebaseUser = _firebaseAuth.currentUser; + if (firebaseUser == null) { + throw Exception('User not authenticated.'); + } + + final StaffSession? session = StaffSessionStore.instance.session; + final String email = session?.user.email ?? ''; + final String? phone = firebaseUser.phoneNumber; + + final fdc.OperationResult + result = await _dataConnect + .createStaff( + userId: firebaseUser.uid, + fullName: fullName, + ) + .bio(bio) + .preferredLocations(preferredLocations) + .maxDistanceMiles(maxDistanceMiles.toInt()) + .industries(industries) + .skills(skills) + .email(email.isEmpty ? null : email) + .phone(phone) + .execute(); + + final String staffId = result.data.staff_insert.id; + + final Staff staff = Staff( + id: staffId, + authProviderId: firebaseUser.uid, + name: fullName, + email: email, + phone: phone, + status: StaffStatus.completedProfile, + ); + + if (session != null) { + StaffSessionStore.instance.setSession( + StaffSession(user: session.user, staff: staff), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart new file mode 100644 index 00000000..8b99f0f9 --- /dev/null +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; + +abstract class ProfileSetupRepository { + Future submitProfile({ + required String fullName, + String? bio, + required List preferredLocations, + required double maxDistanceMiles, + required List industries, + required List skills, + }); +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart new file mode 100644 index 00000000..b69f5fe6 --- /dev/null +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart @@ -0,0 +1,25 @@ +import '../repositories/profile_setup_repository.dart'; + +class SubmitProfileSetup { + final ProfileSetupRepository repository; + + SubmitProfileSetup(this.repository); + + Future call({ + required String fullName, + String? bio, + required List preferredLocations, + required double maxDistanceMiles, + required List industries, + required List skills, + }) { + return repository.submitProfile( + fullName: fullName, + bio: bio, + preferredLocations: preferredLocations, + maxDistanceMiles: maxDistanceMiles, + industries: industries, + skills: skills, + ); + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart index 93d8b44f..324ea906 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart @@ -1,8 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart' as auth; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_domain/krow_domain.dart'; +import '../../../domain/usecases/submit_profile_setup_usecase.dart'; import 'profile_setup_event.dart'; import 'profile_setup_state.dart'; @@ -13,10 +10,8 @@ export 'profile_setup_state.dart'; /// BLoC responsible for managing the profile setup state and logic. class ProfileSetupBloc extends Bloc { ProfileSetupBloc({ - required auth.FirebaseAuth firebaseAuth, - required dc.ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect, + required SubmitProfileSetup submitProfileSetup, + }) : _submitProfileSetup = submitProfileSetup, super(const ProfileSetupState()) { on(_onFullNameChanged); on(_onBioChanged); @@ -27,8 +22,7 @@ class ProfileSetupBloc extends Bloc { on(_onSubmitted); } - final auth.FirebaseAuth _firebaseAuth; - final dc.ExampleConnector _dataConnect; + final SubmitProfileSetup _submitProfileSetup; /// Handles the [ProfileSetupFullNameChanged] event. void _onFullNameChanged( @@ -86,44 +80,14 @@ class ProfileSetupBloc extends Bloc { emit(state.copyWith(status: ProfileSetupStatus.loading)); try { - final auth.User? firebaseUser = _firebaseAuth.currentUser; - if (firebaseUser == null) { - throw Exception('User not authenticated.'); - } - - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - final String email = session?.user.email ?? ''; - final String? phone = firebaseUser.phoneNumber; - - final fdc.OperationResult - result = await _dataConnect - .createStaff( - userId: firebaseUser.uid, - fullName: state.fullName, - ) - .bio(state.bio.isEmpty ? null : state.bio) - .preferredLocations(state.preferredLocations) - .maxDistanceMiles(state.maxDistanceMiles.toInt()) - .industries(state.industries) - .skills(state.skills) - .email(email.isEmpty ? null : email) - .phone(phone) - .execute(); - - final String staffId = result.data.staff_insert.id ; - final Staff staff = Staff( - id: staffId, - authProviderId: firebaseUser.uid, - name: state.fullName, - email: email, - phone: phone, - status: StaffStatus.completedProfile, + await _submitProfileSetup( + fullName: state.fullName, + bio: state.bio.isEmpty ? null : state.bio, + preferredLocations: state.preferredLocations, + maxDistanceMiles: state.maxDistanceMiles, + industries: state.industries, + skills: state.skills, ); - if (session != null) { - dc.StaffSessionStore.instance.setSession( - dc.StaffSession(user: session.user, staff: staff), - ); - } emit(state.copyWith(status: ProfileSetupStatus.success)); } catch (e) { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart index a4596b2d..7e7ead4b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; +import 'package:core_localization/core_localization.dart'; class GetStartedActions extends StatelessWidget { final VoidCallback onSignUpPressed; @@ -13,40 +14,15 @@ class GetStartedActions extends StatelessWidget { @override Widget build(BuildContext context) { + final TranslationsStaffAuthenticationGetStartedPageEn i18n = + t.staff_authentication.get_started_page; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton( - onPressed: onSignUpPressed, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Create Account', - style: UiTypography.buttonL.copyWith(color: Colors.white), - ), - ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: onLoginPressed, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.primary, - side: const BorderSide(color: UiColors.primary), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Log In', - style: UiTypography.buttonL.copyWith(color: UiColors.primary), - ), - ), + spacing: UiConstants.space4, + children: [ + UiButton.primary(onPressed: onSignUpPressed, text: i18n.sign_up_button), + UiButton.secondary(onPressed: onLoginPressed, text: i18n.log_in_button), ], ); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart index f6c940e1..e2b37211 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart @@ -1,26 +1,42 @@ import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; +import 'package:staff_authentication/staff_authentication.dart'; +/// A widget that displays the welcome text and description on the Get Started page. class GetStartedHeader extends StatelessWidget { + /// Creates a [GetStartedHeader]. const GetStartedHeader({super.key}); @override Widget build(BuildContext context) { + final TranslationsStaffAuthenticationGetStartedPageEn i18n = + t.staff_authentication.get_started_page; + return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Krow Workforce', - style: UiTypography.display1b.copyWith(color: UiColors.textPrimary), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RichText( textAlign: TextAlign.center, + text: TextSpan( + style: UiTypography.displayM, + children: [ + TextSpan( + text: i18n.title_part1, + ), + TextSpan( + text: i18n.title_part2, + style: UiTypography.displayMb.textLink, + ), + ], + ), ), const SizedBox(height: 16), Text( - 'Find flexible shifts that fit your schedule.', - style: UiTypography.body1r.copyWith(color: UiColors.textSecondary), + i18n.subtitle, textAlign: TextAlign.center, + style: UiTypography.body1r.textSecondary, ), ], ); } -} +} \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart index 19421456..b98c5356 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart @@ -8,6 +8,9 @@ import 'package:staff_authentication/src/data/repositories_impl/auth_repository_ import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart'; import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart'; +import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart'; +import 'package:staff_authentication/src/data/repositories_impl/profile_setup_repository_impl.dart'; +import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart'; import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart'; import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart'; import 'package:staff_authentication/src/presentation/pages/get_started_page.dart'; @@ -35,10 +38,17 @@ class StaffAuthenticationModule extends Module { dataConnect: ExampleConnector.instance, ), ); + i.addLazySingleton( + () => ProfileSetupRepositoryImpl( + firebaseAuth: firebase.FirebaseAuth.instance, + dataConnect: ExampleConnector.instance, + ), + ); // UseCases i.addLazySingleton(SignInWithPhoneUseCase.new); i.addLazySingleton(VerifyOtpUseCase.new); + i.addLazySingleton(SubmitProfileSetup.new); // BLoCs i.addLazySingleton( @@ -49,8 +59,7 @@ class StaffAuthenticationModule extends Module { ); i.add( () => ProfileSetupBloc( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, + submitProfileSetup: i.get(), ), ); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 51247a7d..508e350a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -1,16 +1,16 @@ import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:staff_home/src/domain/entities/shift.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_core/core.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; extension TimestampExt on Timestamp { DateTime toDate() { - return DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000 + nanoseconds ~/ 1000000); + return DateTimeUtils.toDeviceTime(toDateTime()); } } - class HomeRepositoryImpl implements HomeRepository { HomeRepositoryImpl(); @@ -63,7 +63,15 @@ class HomeRepositoryImpl implements HomeRepository { final response = await ExampleConnector.instance.listShifts().execute(); return response.data.shifts - .where((s) => s.status is Known && (s.status as Known).value == ShiftStatus.OPEN) + .where((s) { + final isOpen = s.status is Known && (s.status as Known).value == ShiftStatus.OPEN; + if (!isOpen) return false; + + final start = s.startTime?.toDate(); + if (start == null) return false; + + return start.isAfter(DateTime.now()); + }) .take(10) .map((s) => _mapConnectorShiftToDomain(s)) .toList(); @@ -72,6 +80,12 @@ class HomeRepositoryImpl implements HomeRepository { } } + @override + Future getStaffName() async { + final session = StaffSessionStore.instance.session; + return session?.staff?.name; + } + // Mappers specific to Home's Domain Entity 'Shift' // Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift. diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart index a280f4cf..df35f9d2 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart @@ -1,4 +1,4 @@ -import 'package:staff_home/src/domain/entities/shift.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Repository interface for home screen data operations. /// @@ -14,4 +14,7 @@ abstract class HomeRepository { /// Retrieves shifts recommended for the worker based on their profile. Future> getRecommendedShifts(); + + /// Retrieves the current staff member's name. + Future getStaffName(); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart index 2e9ed17c..dd8d7958 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart @@ -1,4 +1,4 @@ -import 'package:staff_home/src/domain/entities/shift.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; /// Use case for fetching all shifts displayed on the home screen. diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart index 27ffb317..792a32eb 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/domain/entities/shift.dart'; import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; @@ -10,9 +10,11 @@ part 'home_state.dart'; /// Simple Cubit to manage home page state (shifts + loading/error). class HomeCubit extends Cubit { final GetHomeShifts _getHomeShifts; + final HomeRepository _repository; HomeCubit(HomeRepository repository) : _getHomeShifts = GetHomeShifts(repository), + _repository = repository, super(const HomeState.initial()); Future loadShifts() async { @@ -20,6 +22,7 @@ class HomeCubit extends Cubit { emit(state.copyWith(status: HomeStatus.loading)); try { final result = await _getHomeShifts.call(); + final name = await _repository.getStaffName(); if (isClosed) return; emit( state.copyWith( @@ -27,6 +30,7 @@ class HomeCubit extends Cubit { todayShifts: result.today, tomorrowShifts: result.tomorrow, recommendedShifts: result.recommended, + staffName: name, // Mock profile status for now, ideally fetched from a user repository isProfileComplete: false, ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart index e67f454b..0713d7a1 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart @@ -9,6 +9,7 @@ class HomeState extends Equatable { final List recommendedShifts; final bool autoMatchEnabled; final bool isProfileComplete; + final String? staffName; final String? errorMessage; const HomeState({ @@ -18,6 +19,7 @@ class HomeState extends Equatable { this.recommendedShifts = const [], this.autoMatchEnabled = false, this.isProfileComplete = false, + this.staffName, this.errorMessage, }); @@ -30,6 +32,7 @@ class HomeState extends Equatable { List? recommendedShifts, bool? autoMatchEnabled, bool? isProfileComplete, + String? staffName, String? errorMessage, }) { return HomeState( @@ -39,18 +42,20 @@ class HomeState extends Equatable { recommendedShifts: recommendedShifts ?? this.recommendedShifts, autoMatchEnabled: autoMatchEnabled ?? this.autoMatchEnabled, isProfileComplete: isProfileComplete ?? this.isProfileComplete, + staffName: staffName ?? this.staffName, errorMessage: errorMessage ?? this.errorMessage, ); } @override List get props => [ - status, - todayShifts, - tomorrowShifts, - recommendedShifts, - autoMatchEnabled, - isProfileComplete, - errorMessage, - ]; -} + status, + todayShifts, + tomorrowShifts, + recommendedShifts, + autoMatchEnabled, + isProfileComplete, + staffName, + errorMessage, + ]; +} \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart index 056a5636..4e8dfc0e 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/navigation/home_navigator.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Extension on [IModularNavigator] providing typed navigation helpers /// for the Staff Home feature (worker home screen). @@ -40,4 +41,9 @@ extension HomeNavigator on IModularNavigator { void pushSettings() { pushNamed('/settings'); } + + /// Navigates to the shift details page for the given [shift]. + void pushShiftDetails(Shift shift) { + pushNamed('/worker-main/shift-details/${shift.id}', arguments: shift); + } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 61ff3d9e..1cbb51fc 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -48,7 +48,12 @@ class WorkerHomePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const HomeHeader(), + BlocBuilder( + buildWhen: (previous, current) => previous.staffName != current.staffName, + builder: (context, state) { + return HomeHeader(userName: state.staffName); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), child: Column( diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_header.dart index ea85d499..17127ce5 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_header.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_header.dart @@ -2,15 +2,21 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; - /// Header widget for the staff home page, using design system tokens. class HomeHeader extends StatelessWidget { + final String? userName; + /// Creates a [HomeHeader]. - const HomeHeader({super.key}); + const HomeHeader({super.key, this.userName}); @override Widget build(BuildContext context) { final headerI18n = t.staff.home.header; + final nameToDisplay = userName ?? headerI18n.user_name_placeholder; + final initial = nameToDisplay.isNotEmpty + ? nameToDisplay[0].toUpperCase() + : 'K'; + return Padding( padding: EdgeInsets.fromLTRB( UiConstants.space4, @@ -18,45 +24,42 @@ class HomeHeader extends StatelessWidget { UiConstants.space4, UiConstants.space3, ), - child:Row( + child: Row( + spacing: UiConstants.space3, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: UiColors.primary.withOpacity(0.2), + width: 2, + ), + ), + child: CircleAvatar( + backgroundColor: UiColors.primary.withOpacity(0.1), + child: Text( + initial, + style: const TextStyle( + color: UiColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: UiColors.primary.withOpacity(0.2), - width: 2, - ), - ), - child: CircleAvatar( - backgroundColor: UiColors.primary.withOpacity(0.1), - child: const Text( - 'K', - style: TextStyle( - color: UiColors.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - headerI18n.welcome_back, - style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground), - ), - Text( - headerI18n.user_name_placeholder, - style: UiTypography.headline4m, - ), - ], + Text( + headerI18n.welcome_back, + style: UiTypography.body3r.textSecondary, ), + Text(nameToDisplay, style: UiTypography.headline4m), ], ), + ], + ), ); } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index 7940ff30..3a4ef59d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; - -import 'package:design_system/design_system.dart'; -import 'package:staff_home/src/domain/entities/shift.dart'; import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:staff_home/src/presentation/navigation/home_navigator.dart'; class RecommendedShiftCard extends StatelessWidget { final Shift shift; @@ -18,13 +19,7 @@ class RecommendedShiftCard extends StatelessWidget { return GestureDetector( onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(recI18n.applied_for(title: shift.title)), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); + Modular.to.pushShiftDetails(shift); }, child: Container( width: 300, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart index 3990fe9c..f2a95f0d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart @@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:intl/intl.dart'; import 'package:design_system/design_system.dart'; -import 'package:staff_home/src/domain/entities/shift.dart'; +import 'package:krow_domain/krow_domain.dart'; class ShiftCard extends StatefulWidget { final Shift shift; diff --git a/apps/mobile/packages/features/staff/home/pubspec.yaml b/apps/mobile/packages/features/staff/home/pubspec.yaml index e35bd26d..8d6afcfd 100644 --- a/apps/mobile/packages/features/staff/home/pubspec.yaml +++ b/apps/mobile/packages/features/staff/home/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: path: ../../../core krow_domain: path: ../../../domain + staff_shifts: + path: ../shifts krow_data_connect: path: ../../../data_connect firebase_data_connect: diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 4ffc3563..1b604078 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:intl/intl.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_core/core.dart'; import '../../domain/repositories/shifts_repository_interface.dart'; class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @@ -51,27 +52,26 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { DateTime? _toDateTime(dynamic t) { if (t == null) return null; - if (t is DateTime) return t; - if (t is String) return DateTime.tryParse(t); - - // Data Connect Timestamp handling - try { - if (t is Timestamp) { - return t.toDateTime(); + DateTime? dt; + if (t is Timestamp) { + dt = t.toDateTime(); + } else if (t is String) { + dt = DateTime.tryParse(t); + } else { + try { + dt = DateTime.tryParse(t.toJson() as String); + } catch (_) { + try { + dt = DateTime.tryParse(t.toString()); + } catch (e) { + dt = null; + } } - } catch (_) {} - - try { - // Fallback for any object that might have a toDate or similar - if (t.runtimeType.toString().contains('Timestamp')) { - return (t as dynamic).toDate(); - } - } catch (_) {} - - try { - return DateTime.tryParse(t.toString()); - } catch (_) {} + } + if (dt != null) { + return DateTimeUtils.toDeviceTime(dt); + } return null; } @@ -127,6 +127,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { status: _mapStatus(status), description: shift.description, durationDays: shift.durationDays, + requiredSlots: shift.requiredSlots, + filledSlots: shift.filledSlots, )); } } @@ -182,6 +184,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { status: s.status?.stringValue.toLowerCase() ?? 'open', description: s.description, durationDays: s.durationDays, + requiredSlots: null, // Basic list doesn't fetch detailed role stats yet + filledSlots: null, )); } @@ -210,6 +214,20 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final s = result.data.shift; if (s == null) return null; + int? required; + int? filled; + try { + final rolesRes = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute(); + if (rolesRes.data.shiftRoles.isNotEmpty) { + required = 0; + filled = 0; + for(var r in rolesRes.data.shiftRoles) { + required = (required ?? 0) + r.count; + filled = (filled ?? 0) + (r.assigned ?? 0); + } + } + } catch (_) {} + final startDt = _toDateTime(s.startTime); final endDt = _toDateTime(s.endTime); final createdDt = _toDateTime(s.createdAt); @@ -229,6 +247,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { status: s.status?.stringValue ?? 'OPEN', description: s.description, durationDays: s.durationDays, + requiredSlots: required, + filledSlots: filled, ); } catch (e) { return null; @@ -236,7 +256,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { } @override - Future applyForShift(String shiftId) async { + Future applyForShift(String shiftId, {bool isInstantBook = false}) async { final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute(); if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift'); @@ -246,8 +266,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { await _dataConnect.createApplication( shiftId: shiftId, staffId: staffId, - roleId: role.id, - status: dc.ApplicationStatus.PENDING, + roleId: role.roleId, + status: isInstantBook ? dc.ApplicationStatus.ACCEPTED : dc.ApplicationStatus.PENDING, origin: dc.ApplicationOrigin.STAFF, ).execute(); } @@ -286,6 +306,22 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { } if (appId == null || roleId == null) { + // If we are rejecting and can't find an application, create one as rejected (declining an available shift) + if (newStatus == dc.ApplicationStatus.REJECTED) { + final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute(); + if (rolesResult.data.shiftRoles.isNotEmpty) { + final role = rolesResult.data.shiftRoles.first; + final staffId = await _getStaffId(); + await _dataConnect.createApplication( + shiftId: shiftId, + staffId: staffId, + roleId: role.id, + status: dc.ApplicationStatus.REJECTED, + origin: dc.ApplicationOrigin.STAFF, + ).execute(); + return; + } + } throw Exception("Application not found for shift $shiftId"); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index f77844e5..c3767fd0 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -18,7 +18,9 @@ abstract interface class ShiftsRepositoryInterface { Future getShiftDetails(String shiftId); /// Applies for a specific open shift. - Future applyForShift(String shiftId); + /// + /// [isInstantBook] determines if the application should be immediately accepted. + Future applyForShift(String shiftId, {bool isInstantBook = false}); /// Accepts a pending shift assignment. Future acceptShift(String shiftId); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart new file mode 100644 index 00000000..a637be4c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart @@ -0,0 +1,11 @@ +import '../repositories/shifts_repository_interface.dart'; + +class ApplyForShiftUseCase { + final ShiftsRepositoryInterface repository; + + ApplyForShiftUseCase(this.repository); + + Future call(String shiftId, {bool isInstantBook = false}) async { + return repository.applyForShift(shiftId, isInstantBook: isInstantBook); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart new file mode 100644 index 00000000..5b225f06 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -0,0 +1,63 @@ +import 'package:bloc/bloc.dart'; +import '../../../domain/usecases/apply_for_shift_usecase.dart'; +import '../../../domain/usecases/decline_shift_usecase.dart'; +import '../../../domain/usecases/get_shift_details_usecase.dart'; +import 'shift_details_event.dart'; +import 'shift_details_state.dart'; + +class ShiftDetailsBloc extends Bloc { + final GetShiftDetailsUseCase getShiftDetails; + final ApplyForShiftUseCase applyForShift; + final DeclineShiftUseCase declineShift; + + ShiftDetailsBloc({ + required this.getShiftDetails, + required this.applyForShift, + required this.declineShift, + }) : super(ShiftDetailsInitial()) { + on(_onLoadDetails); + on(_onBookShift); + on(_onDeclineShift); + } + + Future _onLoadDetails( + LoadShiftDetailsEvent event, + Emitter emit, + ) async { + emit(ShiftDetailsLoading()); + try { + final shift = await getShiftDetails(event.shiftId); + if (shift != null) { + emit(ShiftDetailsLoaded(shift)); + } else { + emit(const ShiftDetailsError("Shift not found")); + } + } catch (e) { + emit(ShiftDetailsError(e.toString())); + } + } + + Future _onBookShift( + BookShiftDetailsEvent event, + Emitter emit, + ) async { + try { + await applyForShift(event.shiftId, isInstantBook: true); + emit(const ShiftActionSuccess("Shift successfully booked!")); + } catch (e) { + emit(ShiftDetailsError(e.toString())); + } + } + + Future _onDeclineShift( + DeclineShiftDetailsEvent event, + Emitter emit, + ) async { + try { + await declineShift(event.shiftId); + emit(const ShiftActionSuccess("Shift declined")); + } catch (e) { + emit(ShiftDetailsError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart new file mode 100644 index 00000000..1080065c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +abstract class ShiftDetailsEvent extends Equatable { + const ShiftDetailsEvent(); + + @override + List get props => []; +} + +class LoadShiftDetailsEvent extends ShiftDetailsEvent { + final String shiftId; + const LoadShiftDetailsEvent(this.shiftId); + + @override + List get props => [shiftId]; +} + +class BookShiftDetailsEvent extends ShiftDetailsEvent { + final String shiftId; + const BookShiftDetailsEvent(this.shiftId); + + @override + List get props => [shiftId]; +} + +class DeclineShiftDetailsEvent extends ShiftDetailsEvent { + final String shiftId; + const DeclineShiftDetailsEvent(this.shiftId); + + @override + List get props => [shiftId]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart new file mode 100644 index 00000000..b1a239c4 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +abstract class ShiftDetailsState extends Equatable { + const ShiftDetailsState(); + + @override + List get props => []; +} + +class ShiftDetailsInitial extends ShiftDetailsState {} + +class ShiftDetailsLoading extends ShiftDetailsState {} + +class ShiftDetailsLoaded extends ShiftDetailsState { + final Shift shift; + const ShiftDetailsLoaded(this.shift); + + @override + List get props => [shift]; +} + +class ShiftDetailsError extends ShiftDetailsState { + final String message; + const ShiftDetailsError(this.message); + + @override + List get props => [message]; +} + +class ShiftActionSuccess extends ShiftDetailsState { + final String message; + const ShiftActionSuccess(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index d2f26c17..d2983315 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -3,14 +3,12 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:meta/meta.dart'; -import '../../../domain/usecases/get_available_shifts_usecase.dart'; import '../../../domain/arguments/get_available_shifts_arguments.dart'; -import '../../../domain/usecases/get_my_shifts_usecase.dart'; -import '../../../domain/usecases/get_pending_assignments_usecase.dart'; +import '../../../domain/usecases/get_available_shifts_usecase.dart'; import '../../../domain/usecases/get_cancelled_shifts_usecase.dart'; import '../../../domain/usecases/get_history_shifts_usecase.dart'; -import '../../../domain/usecases/accept_shift_usecase.dart'; -import '../../../domain/usecases/decline_shift_usecase.dart'; +import '../../../domain/usecases/get_my_shifts_usecase.dart'; +import '../../../domain/usecases/get_pending_assignments_usecase.dart'; part 'shifts_event.dart'; part 'shifts_state.dart'; @@ -21,8 +19,6 @@ class ShiftsBloc extends Bloc { final GetPendingAssignmentsUseCase getPendingAssignments; final GetCancelledShiftsUseCase getCancelledShifts; final GetHistoryShiftsUseCase getHistoryShifts; - final AcceptShiftUseCase acceptShift; - final DeclineShiftUseCase declineShift; ShiftsBloc({ required this.getMyShifts, @@ -30,13 +26,9 @@ class ShiftsBloc extends Bloc { required this.getPendingAssignments, required this.getCancelledShifts, required this.getHistoryShifts, - required this.acceptShift, - required this.declineShift, }) : super(ShiftsInitial()) { on(_onLoadShifts); on(_onFilterAvailableShifts); - on(_onAcceptShift); - on(_onDeclineShift); } Future _onLoadShifts( @@ -63,7 +55,7 @@ class ShiftsBloc extends Bloc { myShifts: myShiftsResult, pendingShifts: pendingResult, cancelledShifts: cancelledResult, - availableShifts: availableResult, + availableShifts: _filterPastShifts(availableResult), historyShifts: historyResult, searchQuery: '', jobType: 'all', @@ -89,7 +81,7 @@ class ShiftsBloc extends Bloc { )); emit(currentState.copyWith( - availableShifts: result, + availableShifts: _filterPastShifts(result), searchQuery: event.query ?? currentState.searchQuery, jobType: event.jobType ?? currentState.jobType, )); @@ -99,27 +91,16 @@ class ShiftsBloc extends Bloc { } } - Future _onAcceptShift( - AcceptShiftEvent event, - Emitter emit, - ) async { - try { - await acceptShift(event.shiftId); - add(LoadShiftsEvent()); // Reload lists - } catch (_) { - // Handle error - } - } - - Future _onDeclineShift( - DeclineShiftEvent event, - Emitter emit, - ) async { - try { - await declineShift(event.shiftId); - add(LoadShiftsEvent()); // Reload lists - } catch (_) { - // Handle error - } + List _filterPastShifts(List shifts) { + final now = DateTime.now(); + return shifts.where((shift) { + if (shift.date.isEmpty) return false; + try { + final shiftDate = DateTime.parse(shift.date); + return shiftDate.isAfter(now); + } catch (_) { + return false; + } + }).toList(); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/navigation/shifts_navigator.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/navigation/shifts_navigator.dart index 4832055b..007b4e00 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/navigation/shifts_navigator.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/navigation/shifts_navigator.dart @@ -3,8 +3,6 @@ import 'package:krow_domain/krow_domain.dart'; extension ShiftsNavigator on IModularNavigator { void pushShiftDetails(Shift shift) { - pushNamed('/shifts/details/${shift.id}', arguments: shift); + pushNamed('/worker-main/shift-details/${shift.id}', arguments: shift); } - - // Example for going back or internal navigation if needed } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index c62b3b15..14889f20 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -1,82 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:intl/intl.dart'; -import 'package:design_system/design_system.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; -import '../../domain/usecases/get_shift_details_usecase.dart'; -import '../../domain/usecases/accept_shift_usecase.dart'; -import '../../domain/usecases/decline_shift_usecase.dart'; +import 'package:design_system/design_system.dart'; // Re-added for UiIcons/Colors as they are used in expanded logic +import 'package:intl/intl.dart'; +import '../blocs/shift_details/shift_details_bloc.dart'; +import '../blocs/shift_details/shift_details_event.dart'; +import '../blocs/shift_details/shift_details_state.dart'; -// Shim to match POC styles locally -class AppColors { - static const Color krowBlue = UiColors.primary; - static const Color krowYellow = Color(0xFFFFED4A); - static const Color krowCharcoal = UiColors.textPrimary; // 121826 - static const Color krowMuted = UiColors.textSecondary; // 6A7382 - static const Color krowBorder = UiColors.border; // E3E6E9 - static const Color krowBackground = UiColors.background; // FAFBFC - static const Color white = Colors.white; -} - -class ShiftDetailsPage extends StatefulWidget { +class ShiftDetailsPage extends StatelessWidget { final String shiftId; final Shift? shift; const ShiftDetailsPage({super.key, required this.shiftId, this.shift}); - @override - State createState() => _ShiftDetailsPageState(); -} - -class _ShiftDetailsPageState extends State { - late Shift _shift; - bool _isLoading = true; - bool _showDetails = true; - bool _isApplying = false; - - - - @override - void initState() { - super.initState(); - _loadShift(); - } - - void _loadShift() async { - if (widget.shift != null) { - _shift = widget.shift!; - setState(() => _isLoading = false); - } else { - try { - final useCase = Modular.get(); - final shift = await useCase(widget.shiftId); - if (mounted) { - if (shift != null) { - setState(() { - _shift = shift; - _isLoading = false; - }); - } else { - // Handle case where shift is not found - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Shift not found')), - ); - } - } - } catch (e) { - if (mounted) { - setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading shift: $e')), - ); - } - } - } - } - String _formatTime(String time) { if (time.isEmpty) return ''; try { @@ -84,7 +21,7 @@ class _ShiftDetailsPageState extends State { final hour = int.parse(parts[0]); final minute = int.parse(parts[1]); final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mma').format(dt).toLowerCase(); + return DateFormat('h:mm a').format(dt); } catch (e) { return time; } @@ -94,773 +31,516 @@ class _ShiftDetailsPageState extends State { if (dateStr.isEmpty) return ''; try { final date = DateTime.parse(dateStr); - return DateFormat('MMMM d').format(date); + return DateFormat('EEEE, MMMM d, y').format(date); } catch (e) { return dateStr; } } - double _calculateHours(String start, String end) { + double _calculateDuration(Shift shift) { + if (shift.startTime.isEmpty || shift.endTime.isEmpty) { + return 0; + } try { - final startParts = start.split(':').map(int.parse).toList(); - final endParts = end.split(':').map(int.parse).toList(); - double h = (endParts[0] - startParts[0]) + (endParts[1] - startParts[1]) / 60; - if (h < 0) h += 24; - return h; - } catch (e) { + final s = shift.startTime.split(':').map(int.parse).toList(); + final e = shift.endTime.split(':').map(int.parse).toList(); + double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } catch (_) { return 0; } } + Widget _buildStatCard(IconData icon, String value, String label) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20, color: UiColors.iconSecondary), + ), + const SizedBox(height: 8), + Text( + value, + style: UiTypography.title1m.copyWith(color: UiColors.textPrimary), + ), + Text( + label, + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildTimeBox(String label, String time) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(time), + style: UiTypography.display2m.copyWith( + fontSize: 20, + color: UiColors.textPrimary, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold( - backgroundColor: AppColors.krowBackground, - body: Center(child: CircularProgressIndicator()), - ); - } + return BlocProvider( + create: (_) => + Modular.get()..add(LoadShiftDetailsEvent(shiftId)), + child: BlocListener( + listener: (context, state) { + if (state is ShiftActionSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: const Color(0xFF10B981), + ), + ); + Modular.to.pop(true); // Return outcome + } else if (state is ShiftDetailsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: const Color(0xFFEF4444), + ), + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state is ShiftDetailsLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } - final hours = _calculateHours(_shift.startTime, _shift.endTime); - final totalPay = _shift.hourlyRate * hours; + Shift? displayShift; + if (state is ShiftDetailsLoaded) { + displayShift = state.shift; + } else { + displayShift = shift; + } - return Scaffold( - backgroundColor: AppColors.krowBackground, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(LucideIcons.chevronLeft, color: AppColors.krowMuted), - onPressed: () => Modular.to.pop(), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: AppColors.krowBorder, height: 1.0), - ), - ), - body: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 120), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Pending Badge - // Status Badge - Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: _getStatusColor(_shift.status ?? 'open').withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - (_shift.status ?? 'open').toUpperCase(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: _getStatusColor(_shift.status ?? 'open'), - ), - ), - ), - ), - const SizedBox(height: 16), + if (displayShift == null) { + return const Scaffold( + body: Center(child: Text("Shift not found")), + ); + } - // Header - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: _shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - _shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : Center( - child: Text( - _shift.clientName.isNotEmpty ? _shift.clientName[0] : 'K', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppColors.krowBlue, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( + final duration = _calculateDuration(displayShift); + final estimatedTotal = (displayShift.hourlyRate) * duration; + final openSlots = + (displayShift.requiredSlots ?? 0) - + (displayShift.filledSlots ?? 0); + + return Scaffold( + appBar: UiAppBar( + title: displayShift.title, + showBackButton: true, + centerTitle: false, + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _shift.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${_shift.hourlyRate.toStringAsFixed(0)}/h', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - Text( - '(exp.total \$${totalPay.toStringAsFixed(0)})', - style: const TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - ], - ), - ], - ), - Text( - _shift.clientName, - style: const TextStyle(color: AppColors.krowMuted), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - - - // Additional Details Collapsible - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - children: [ - InkWell( - onTap: () => - setState(() => _showDetails = !_showDetails), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + // Vendor Section + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'ADDITIONAL DETAILS', + "VENDOR", style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, letterSpacing: 0.5, - color: AppColors.krowMuted, ), ), - Icon( - _showDetails - ? LucideIcons.chevronUp - : LucideIcons.chevronDown, - color: AppColors.krowMuted, - size: 20, - ), - ], - ), - ), - ), - if (_showDetails) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - children: [ - _buildDetailRow('Tips', _shift.tipsAvailable == true ? 'Yes' : 'No', _shift.tipsAvailable == true), - _buildDetailRow('Travel Time', _shift.travelTime == true ? 'Yes' : 'No', _shift.travelTime == true), - _buildDetailRow('Meal Provided', _shift.mealProvided == true ? 'Yes' : 'No', _shift.mealProvided == true), - _buildDetailRow('Parking Available', _shift.parkingAvailable == true ? 'Yes' : 'No', _shift.parkingAvailable == true), - _buildDetailRow('Gas Compensation', _shift.gasCompensation == true ? 'Yes' : 'No', _shift.gasCompensation == true), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Date & Duration Grid - Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'START', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 8), - Text( - _formatDate(_shift.date), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - const Text( - 'Date', - style: TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 12), - Text( - _formatTime(_shift.startTime), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - const Text( - 'Time', - style: TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'DURATION', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 8), - Text( - '${hours.toStringAsFixed(0)} hours', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - const Text( - 'Shift duration', - style: TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 12), - const Text( - '1 hour', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - const Text( - 'Break duration', - style: TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Location - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'LOCATION', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _shift.location, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.krowCharcoal, - ), - ), - Text( - _shift.locationAddress, - style: const TextStyle( - fontSize: 14, - color: AppColors.krowMuted, - ), - ), - ], - ), - ), - OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - _shift.locationAddress, - ), - duration: const Duration(seconds: 3), - ), - ); - }, - icon: const Icon(LucideIcons.navigation, size: 14), - label: const Text('Get direction'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.krowCharcoal, - side: const BorderSide( - color: AppColors.krowBorder, - ), - textStyle: const TextStyle(fontSize: 12), - ), - ), - ], - ), - const SizedBox(height: 16), - Container( - height: 160, - width: double.infinity, - decoration: BoxDecoration( - color: const Color(0xFFF1F3F5), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Icon( - LucideIcons.map, - color: AppColors.krowMuted, - size: 48, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Manager Contact - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'MANAGER CONTACT DETAILS', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 16), - ...(_shift.managers ?? []) - .map( - (manager) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + const SizedBox(height: 8), + Row( children: [ - Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - AppColors.krowBlue, - Color(0xFF0830B8), - ], - ), - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: _buildAvatar(manager), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - manager.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.krowCharcoal, + Container( + width: 24, + height: 24, + child: displayShift.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular( + 6, + ), + child: Image.network( + displayShift.logoUrl!, + fit: BoxFit.cover, + ), + ) + : const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, ), ), - Text( - manager.phone, - style: const TextStyle( - fontSize: 12, - color: AppColors.krowMuted, - ), - ), - ], - ), - ], ), - OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text(manager.phone), - duration: const Duration(seconds: 3), + const SizedBox(width: 8), + Text( + displayShift.clientName, + style: UiTypography.headline5m.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // Date Section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "SHIFT DATE", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: 8), + Text( + _formatDate(displayShift.date), + style: UiTypography.headline5m.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // Worker Capacity / Open Slots + if ((displayShift.requiredSlots ?? 0) > 0) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), // green-50 + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFBBF7D0), + ), // green-200 + ), + child: Row( + children: [ + const Icon( + Icons.people_alt_outlined, + size: 20, + color: Color(0xFF15803D), + ), // green-700, using Material Icon as generic fallback + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + "$openSlots spots remaining", + style: UiTypography.body2b.copyWith( + color: const Color(0xFF15803D), + ), ), - ); - }, - icon: const Icon( - LucideIcons.phone, - size: 14, - color: Color(0xFF059669), + Text( + "${displayShift.filledSlots ?? 0} filled out of ${displayShift.requiredSlots}", + style: UiTypography.body3r.copyWith( + color: const Color(0xFF166534), + ), + ), + ], ), - label: const Text( - 'Call', - style: TextStyle( - color: Color(0xFF059669), - ), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: Color(0xFFA7F3D0), - ), - backgroundColor: const Color(0xFFECFDF5), - textStyle: const TextStyle(fontSize: 12), + ), + SizedBox( + width: 60, + child: LinearProgressIndicator( + value: (displayShift.requiredSlots! > 0) + ? (displayShift.filledSlots ?? 0) / + displayShift.requiredSlots! + : 0, + backgroundColor: Colors.white, + color: const Color(0xFF15803D), + minHeight: 6, + borderRadius: BorderRadius.circular(3), ), ), ], ), ), - ) - .toList(), - ], - ), - ), - const SizedBox(height: 16), + const SizedBox(height: 24), - // Additional Info - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.krowBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'ADDITIONAL INFO', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: AppColors.krowMuted, - ), - ), - const SizedBox(height: 12), - Text( - _shift.description ?? - 'Providing Exceptional Customer Service.', - style: const TextStyle( - fontSize: 14, - color: AppColors.krowMuted, - height: 1.5, - ), - ), - ], - ), - ), - ], - ), - ), - - // Bottom Actions - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: AppColors.krowBorder)), - ), - child: SafeArea( - top: false, - child: Column( - children: [ - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () async { - setState(() => _isApplying = true); - try { - final acceptUseCase = Modular.get(); - await acceptUseCase(_shift.id); - - if (mounted) { - setState(() => _isApplying = false); - Modular.to.pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift Accepted!'), - backgroundColor: Color(0xFF10B981), - ), - ); - // Ideally, trigger a refresh on the previous screen - Modular.get().add(LoadShiftsEvent()); - } - } catch (e) { - if (mounted) { - setState(() => _isApplying = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to accept shift: $e'), - backgroundColor: const Color(0xFFEF4444), - ), - ); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.krowBlue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + // Stats Grid + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + crossAxisSpacing: 12, + childAspectRatio: 0.85, + children: [ + _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total Pay", + ), + _buildStatCard( + UiIcons.dollar, + "\$${displayShift.hourlyRate.toInt()}", + "Per Hour", + ), + _buildStatCard( + UiIcons.clock, + "${duration.toInt()}h", + "Duration", + ), + ], ), - elevation: 0, - ), - child: _isApplying - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - color: Colors.white, - ), - ) - : const Text( - 'Accept shift', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, + const SizedBox(height: 24), + + // Shift Timing + Row( + children: [ + Expanded( + child: _buildTimeBox( + "START TIME", + displayShift.startTime, ), ), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 48, - child: TextButton( - onPressed: () async { - try { - final declineUseCase = Modular.get(); - await declineUseCase(_shift.id); - - if (mounted) { - Modular.to.pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift Declined'), - backgroundColor: Color(0xFFEF4444), + const SizedBox(width: 12), + Expanded( + child: _buildTimeBox( + "END TIME", + displayShift.endTime, ), - ); - // Refresh list - Modular.get().add(LoadShiftsEvent()); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to decline shift: $e'), - backgroundColor: const Color(0xFFEF4444), - ), - ); - } - } - }, - child: const Text( - 'Decline shift', - style: TextStyle( - color: Color(0xFFEF4444), - fontSize: 16, - fontWeight: FontWeight.w500, + ), + ], ), - ), + const SizedBox(height: 24), + + // Location + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "LOCATION", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayShift.location.isEmpty + ? "TBD" + : displayShift.location, + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + displayShift.location.isEmpty + ? "TBD" + : displayShift.locationAddress, + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // Additional Info + if (displayShift.description != null) ...[ + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "ADDITIONAL INFO", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + displayShift.description!, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => + _declineShift(context, displayShift!.id), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFEF4444), + side: const BorderSide( + color: Color(0xFFEF4444), + ), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + ), + child: const Text("Decline"), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () => + _bookShift(context, displayShift!.id), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + ), + child: const Text("Book Shift"), + ), + ), + ], + ), + SizedBox( + height: MediaQuery.of(context).padding.bottom + 10, + ), + ], ), ), - ], - ), + ), + ], ), + ); + }, + ), + ), + ); + } + + void _bookShift(BuildContext context, String id) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Book Shift'), + content: const Text('Do you want to instantly book this shift?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + BlocProvider.of( + context, + ).add(BookShiftDetailsEvent(id)); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF10B981), ), + child: const Text('Book'), ), ], ), ); } - Widget _buildTag(IconData icon, String label, Color bg, Color text) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - children: [ - Icon(icon, size: 14, color: text), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: text, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + void _declineShift(BuildContext context, String id) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Decline Shift'), + content: const Text( + 'Are you sure you want to decline this shift? It will be hidden from your available jobs.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), ), - ], - ), - ); - } - - Color _getStatusColor(String status) { - switch (status.toLowerCase()) { - case 'confirmed': - case 'accepted': - return const Color(0xFF10B981); // Green - case 'pending': - return const Color(0xFFF59E0B); // Yellow - case 'cancelled': - case 'rejected': - return const Color(0xFFEF4444); // Red - case 'completed': - return const Color(0xFF10B981); - default: - return AppColors.krowBlue; - } - } - - Widget _buildAvatar(ShiftManager manager) { - if (manager.avatar != null && manager.avatar!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network(manager.avatar!, fit: BoxFit.cover), - ); - } - return const Center( - child: Icon( - LucideIcons.user, - color: Colors.white, - size: 20, - ), - ); - } - - Widget _buildDetailRow(String label, String value, bool isPositive) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: const TextStyle(fontSize: 14, color: AppColors.krowMuted), - ), - Text( - value, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isPositive ? const Color(0xFF059669) : AppColors.krowMuted, + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + BlocProvider.of( + context, + ).add(DeclineShiftDetailsEvent(id)); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFEF4444), ), + child: const Text('Decline'), ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index adf0e07e..1a0dd2a5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -1,25 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:intl/intl.dart'; import 'package:design_system/design_system.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/shifts/shifts_bloc.dart'; -import '../widgets/my_shift_card.dart'; -import '../widgets/shift_assignment_card.dart'; - -// Shim to match POC styles locally -class AppColors { - static const Color krowBlue = UiColors.primary; - static const Color krowYellow = Color(0xFFFFED4A); - static const Color krowCharcoal = UiColors.textPrimary; - static const Color krowMuted = UiColors.textSecondary; - static const Color krowBorder = UiColors.border; - static const Color krowBackground = UiColors.background; - static const Color white = Colors.white; - static const Color black = Colors.black; -} +import '../widgets/tabs/my_shifts_tab.dart'; +import '../widgets/tabs/find_shifts_tab.dart'; +import '../widgets/tabs/history_shifts_tab.dart'; +import '../styles/shifts_styles.dart'; class ShiftsPage extends StatefulWidget { final String? initialTab; @@ -31,15 +19,6 @@ class ShiftsPage extends StatefulWidget { class _ShiftsPageState extends State { late String _activeTab; - String _searchQuery = ''; - // ignore: unused_field - String? _cancelledShiftDemo; // 'lastMinute' or 'advance' - String _jobType = 'all'; // all, one-day, multi-day, long-term - - // Calendar State - DateTime _selectedDate = DateTime.now(); - int _weekOffset = 0; - final ShiftsBloc _bloc = Modular.get(); @override @@ -59,93 +38,30 @@ class _ShiftsPageState extends State { } } - List _getCalendarDays() { - final now = DateTime.now(); - int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - int daysSinceFriday = (reactDayIndex + 2) % 7; - final start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: _weekOffset * 7)); - final startDate = DateTime(start.year, start.month, start.day); - return List.generate(7, (index) => startDate.add(Duration(days: index))); - } - - bool _isSameDay(DateTime a, DateTime b) { - return a.year == b.year && a.month == b.month && a.day == b.day; - } - - void _confirmShift(String id) { - _bloc.add(AcceptShiftEvent(id)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift confirmed!'), - backgroundColor: Color(0xFF10B981), - ), - ); - } - - void _declineShift(String id) { - _bloc.add(DeclineShiftEvent(id)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift declined.'), - backgroundColor: Color(0xFFEF4444), - ), - ); - } - @override Widget build(BuildContext context) { return BlocProvider.value( value: _bloc, child: BlocBuilder( builder: (context, state) { - final List myShifts = (state is ShiftsLoaded) ? state.myShifts : []; - final List availableJobs = (state is ShiftsLoaded) ? state.availableShifts : []; - final List pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : []; - final List cancelledShifts = (state is ShiftsLoaded) ? state.cancelledShifts : []; - final List historyShifts = (state is ShiftsLoaded) ? state.historyShifts : []; + final List myShifts = (state is ShiftsLoaded) + ? state.myShifts + : []; + final List availableJobs = (state is ShiftsLoaded) + ? state.availableShifts + : []; + final List pendingAssignments = (state is ShiftsLoaded) + ? state.pendingShifts + : []; + final List cancelledShifts = (state is ShiftsLoaded) + ? state.cancelledShifts + : []; + final List historyShifts = (state is ShiftsLoaded) + ? state.historyShifts + : []; - // Filter logic - final filteredJobs = availableJobs.where((s) { - final matchesSearch = - s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || - s.location.toLowerCase().contains(_searchQuery.toLowerCase()) || - s.clientName.toLowerCase().contains(_searchQuery.toLowerCase()); - - if (!matchesSearch) return false; - - if (_jobType == 'all') return true; - if (_jobType == 'one-day') { - return s.durationDays == null || s.durationDays! <= 1; - } - if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1; - return true; - }).toList(); - - final calendarDays = _getCalendarDays(); - final weekStartDate = calendarDays.first; - final weekEndDate = calendarDays.last; - - final visibleMyShifts = myShifts.where((s) { - try { - final date = DateTime.parse(s.date); - return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) && - date.isBefore(weekEndDate.add(const Duration(days: 1))); - } catch (_) { - return false; - } - }).toList(); - - final visibleCancelledShifts = cancelledShifts.where((s) { - try { - final date = DateTime.parse(s.date); - return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) && - date.isBefore(weekEndDate.add(const Duration(days: 1))); - } catch (_) { - return false; - } - }).toList(); + // Note: "filteredJobs" logic moved to FindShiftsTab + // Note: Calendar logic moved to MyShiftsTab return Scaffold( backgroundColor: AppColors.krowBackground, @@ -161,326 +77,58 @@ class _ShiftsPageState extends State { 20, ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Shifts", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon(UiIcons.user, size: 20, color: Colors.white), - ), - ), - ], + const Text( + "Shifts", + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), - const SizedBox(height: 16), + // Tabs Row( children: [ - _buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length), + _buildTab( + "myshifts", + "My Shifts", + UiIcons.calendar, + myShifts.length, + ), const SizedBox(width: 8), - _buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length), + _buildTab( + "find", + "Find Shifts", + UiIcons.search, + availableJobs.length, // Passed unfiltered count as badge? Or logic inside? Pass availableJobs. + ), const SizedBox(width: 8), - _buildTab("history", "History", UiIcons.clock, historyShifts.length), + _buildTab( + "history", + "History", + UiIcons.clock, + historyShifts.length, + ), ], ), ], ), ), - // Calendar Selector - if (_activeTab == 'myshifts') - Container( - color: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(UiIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal), - onPressed: () => setState(() => _weekOffset--), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - Text( - DateFormat('MMMM yyyy').format(weekStartDate), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.krowCharcoal, - ), - ), - IconButton( - icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal), - onPressed: () => setState(() => _weekOffset++), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - ], - ), - ), - // Days Grid - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((date) { - final isSelected = _isSameDay(date, _selectedDate); - final dateStr = DateFormat('yyyy-MM-dd').format(date); - final hasShifts = myShifts.any((s) { - try { - return _isSameDay(DateTime.parse(s.date), date); - } catch (_) { return false; } - }); - - return GestureDetector( - onTap: () => setState(() => _selectedDate = date), - child: Column( - children: [ - Container( - width: 44, - height: 60, - decoration: BoxDecoration( - color: isSelected ? AppColors.krowBlue : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected ? AppColors.krowBlue : AppColors.krowBorder, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - date.day.toString().padLeft(2, '0'), - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: isSelected ? Colors.white : AppColors.krowCharcoal, - ), - ), - Text( - DateFormat('E').format(date), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted, - ), - ), - if (hasShifts && !isSelected) - Container( - margin: const EdgeInsets.only(top: 4), - width: 4, - height: 4, - decoration: const BoxDecoration( - color: AppColors.krowBlue, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ), - ], - ), - ), - - if (_activeTab == 'myshifts') - const Divider(height: 1, color: AppColors.krowBorder), - - // Search and Filters for Find Tab (Fixed at top) - if (_activeTab == 'find') - Container( - color: Colors.white, - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Column( - children: [ - // Search Bar - Row( - children: [ - Expanded( - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: Row( - children: [ - const Icon(UiIcons.search, size: 20, color: Color(0xFF94A3B8)), - const SizedBox(width: 10), - Expanded( - child: TextField( - onChanged: (v) => setState(() => _searchQuery = v), - decoration: const InputDecoration( - border: InputBorder.none, - hintText: "Search jobs, location...", - hintStyle: TextStyle( - color: Color(0xFF94A3B8), - fontSize: 14, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - Container( - height: 48, - width: 48, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: const Icon(UiIcons.filter, size: 18, color: Color(0xFF64748B)), - ), - ], - ), - const SizedBox(height: 16), - // Filter Tabs - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildFilterTab('all', 'All Jobs'), - const SizedBox(width: 8), - _buildFilterTab('one-day', 'One Day'), - const SizedBox(width: 8), - _buildFilterTab('multi-day', 'Multi-Day'), - const SizedBox(width: 8), - _buildFilterTab('long-term', 'Long Term'), - ], - ), - ), - ], - ), - ), - // Body Content Expanded( - child: state is ShiftsLoading - ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 20), - if (_activeTab == 'myshifts') ...[ - if (pendingAssignments.isNotEmpty) ...[ - _buildSectionHeader("Awaiting Confirmation", const Color(0xFFF59E0B)), - ...pendingAssignments.map((shift) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ShiftAssignmentCard( - shift: shift, - onConfirm: () => _confirmShift(shift.id), - onDecline: () => _declineShift(shift.id), - isConfirming: true, - ), - )), - const SizedBox(height: 12), - ], - - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader("Cancelled Shifts", AppColors.krowMuted), - ...visibleCancelledShifts.map((shift) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _buildCancelledCard( - title: shift.title, - client: shift.clientName, - pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}", - rate: "\$${shift.hourlyRate}/hr · 8h", - date: _formatDateStr(shift.date), - time: "${shift.startTime} - ${shift.endTime}", - address: shift.locationAddress, - isLastMinute: true, - onTap: () {} - ), - )), - const SizedBox(height: 12), - ], - - // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader("Confirmed Shifts", AppColors.krowMuted), - ...visibleMyShifts.map((shift) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: MyShiftCard(shift: shift), - )), - ], - - if (visibleMyShifts.isEmpty && pendingAssignments.isEmpty && cancelledShifts.isEmpty) - _buildEmptyState(UiIcons.calendar, "No shifts this week", "Try finding new jobs in the Find tab", null, null), - ], - - if (_activeTab == 'find') ...[ - if (filteredJobs.isEmpty) - _buildEmptyState(UiIcons.search, "No jobs available", "Check back later", null, null) - else - ...filteredJobs.map((shift) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: MyShiftCard( - shift: shift, - onAccept: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift Booked!'), - backgroundColor: Color(0xFF10B981), - ), - ); - }, - onDecline: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift Declined'), - backgroundColor: Color(0xFFEF4444), - ), - ); - }, - ), - )), - ], - - if (_activeTab == 'history') ...[ - if (historyShifts.isEmpty) - _buildEmptyState(UiIcons.clock, "No shift history", "Completed shifts appear here", null, null) - else - ...historyShifts.map((shift) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: MyShiftCard( - shift: shift, - historyMode: true, - ), - )), - ], - - const SizedBox(height: 40), - ], - ), - ), + child: state is ShiftsLoading + ? const Center(child: CircularProgressIndicator()) + : _buildTabContent( + myShifts, + pendingAssignments, + cancelledShifts, + availableJobs, + historyShifts, + ), ), ], ), @@ -490,62 +138,33 @@ class _ShiftsPageState extends State { ); } - String _formatDateStr(String dateStr) { - try { - final date = DateTime.parse(dateStr); - final now = DateTime.now(); - if (_isSameDay(date, now)) return "Today"; - final tomorrow = now.add(const Duration(days: 1)); - if (_isSameDay(date, tomorrow)) return "Tomorrow"; - return DateFormat('EEE, MMM d').format(date); - } catch (_) { - return dateStr; + Widget _buildTabContent( + List myShifts, + List pendingAssignments, + List cancelledShifts, + List availableJobs, + List historyShifts, + ) { + switch (_activeTab) { + case 'myshifts': + return MyShiftsTab( + myShifts: myShifts, + pendingAssignments: pendingAssignments, + cancelledShifts: cancelledShifts, + ); + case 'find': + return FindShiftsTab( + availableJobs: availableJobs, + ); + case 'history': + return HistoryShiftsTab( + historyShifts: historyShifts, + ); + default: + return const SizedBox.shrink(); } } - Widget _buildSectionHeader(String title, Color dotColor) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - children: [ - Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)), - const SizedBox(width: 8), - Text(title, style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: dotColor == AppColors.krowMuted ? AppColors.krowMuted : dotColor - )), - ], - ), - ); - } - - Widget _buildFilterTab(String id, String label) { - final isSelected = _jobType == id; - return GestureDetector( - onTap: () => setState(() => _jobType = id), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? AppColors.krowBlue : Colors.white, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: isSelected ? AppColors.krowBlue : const Color(0xFFE2E8F0), - ), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : const Color(0xFF64748B), - ), - ), - ), - ); - } - Widget _buildTab(String id, String label, IconData icon, int count) { final isActive = _activeTab == id; return Expanded( @@ -554,112 +173,57 @@ class _ShiftsPageState extends State { child: Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: BoxDecoration( - color: isActive ? Colors.white : Colors.white.withAlpha((0.2 * 255).round()), - borderRadius: BorderRadius.circular(8), + color: isActive + ? Colors.white + : Colors.white.withAlpha((0.2 * 255).round()), + borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: isActive ? AppColors.krowBlue : Colors.white), - const SizedBox(width: 6), - Flexible(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isActive ? AppColors.krowBlue : Colors.white), overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - constraints: const BoxConstraints(minWidth: 18), - decoration: BoxDecoration( - color: isActive ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) : Colors.white.withAlpha((0.2 * 255).round()), - borderRadius: BorderRadius.circular(999), + Icon( + icon, + size: 14, + color: isActive ? AppColors.krowBlue : Colors.white, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isActive ? AppColors.krowBlue : Colors.white, ), - child: Center(child: Text("$count", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isActive ? AppColors.krowBlue : Colors.white))), - ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + constraints: const BoxConstraints(minWidth: 18), + decoration: BoxDecoration( + color: isActive + ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) + : Colors.white.withAlpha((0.2 * 255).round()), + borderRadius: BorderRadius.circular(999), + ), + child: Center( + child: Text( + "$count", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isActive ? AppColors.krowBlue : Colors.white, + ), + ), + ), + ), ], ), ), ), ); } - - Widget _buildEmptyState(IconData icon, String title, String subtitle, String? actionLabel, VoidCallback? onAction) { - return Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 64), child: Column(children: [ - Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)), - const SizedBox(height: 16), - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.krowCharcoal)), - const SizedBox(height: 4), - Text(subtitle, style: const TextStyle(fontSize: 14, color: AppColors.krowMuted)), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(height: 16), - ElevatedButton(onPressed: onAction, style: ElevatedButton.styleFrom(backgroundColor: AppColors.krowBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), child: Text(actionLabel)), - ] - ]))); - } - - Widget _buildCancelledCard({required String title, required String client, required String pay, required String rate, required String date, required String time, required String address, required bool isLastMinute, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.krowBorder) - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), - const SizedBox(width: 6), - const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), - if (isLastMinute) ...[ - const SizedBox(width: 4), - const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981))) - ] - ]), - const SizedBox(height: 12), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppColors.krowBlue.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - ), - child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20)) - ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), - Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)) - ])), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), - Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted)) - ]) - ]), - const SizedBox(height: 8), - Row(children: [ - const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), - const SizedBox(width: 4), - Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), - const SizedBox(width: 12), - const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), - const SizedBox(width: 4), - Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)) - ]), - const SizedBox(height: 4), - Row(children: [ - const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), - const SizedBox(width: 4), - Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis)) - ]), - ])), - ]), - ]), - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart new file mode 100644 index 00000000..7f98111b --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; + +class AppColors { + static const Color krowBlue = UiColors.primary; + static const Color krowYellow = Color(0xFFFFED4A); + static const Color krowCharcoal = UiColors.textPrimary; + static const Color krowMuted = UiColors.textSecondary; + static const Color krowBorder = UiColors.border; + static const Color krowBackground = UiColors.background; + static const Color white = Colors.white; + static const Color black = Colors.black; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index c24fa6c1..7175e004 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -1,25 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; +import 'package:staff_shifts/src/presentation/navigation/shifts_navigator.dart'; class MyShiftCard extends StatefulWidget { final Shift shift; - final bool historyMode; - final VoidCallback? onAccept; - final VoidCallback? onDecline; - final VoidCallback? onRequestSwap; - final int index; const MyShiftCard({ super.key, required this.shift, - this.historyMode = false, - this.onAccept, - this.onDecline, - this.onRequestSwap, - this.index = 0, }); @override @@ -27,8 +19,6 @@ class MyShiftCard extends StatefulWidget { } class _MyShiftCardState extends State { - bool _isExpanded = false; - String _formatTime(String time) { if (time.isEmpty) return ''; try { @@ -120,9 +110,10 @@ class _MyShiftCardState extends State { } return GestureDetector( - onTap: () => setState(() => _isExpanded = !_isExpanded), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), + onTap: () { + Modular.to.pushShiftDetails(widget.shift); + }, + child: Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, @@ -389,384 +380,9 @@ class _MyShiftCardState extends State { ], ), ), - - // Expanded Content - AnimatedSize( - duration: const Duration(milliseconds: 300), - child: _isExpanded - ? Column( - children: [ - const Divider(height: 1, color: UiColors.border), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // Stats Row - Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${widget.shift.hourlyRate.toInt()}", - "Hourly Rate", - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildStatCard( - UiIcons.clock, - "${duration.toInt()}", - "Hours", - ), - ), - ], - ), - const SizedBox(height: 24), - - // In/Out Time - Row( - children: [ - Expanded( - child: _buildTimeBox( - "CLOCK IN TIME", - widget.shift.startTime, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildTimeBox( - "CLOCK OUT TIME", - widget.shift.endTime, - ), - ), - ], - ), - const SizedBox(height: 24), - - // Location - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "LOCATION", - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.shift.location.isEmpty - ? "TBD" - : widget.shift.location, - style: UiTypography.title1m.copyWith( - color: UiColors.textPrimary, - ), - ), - OutlinedButton.icon( - onPressed: () { - // Show snackbar with the address - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - widget.shift.locationAddress, - ), - duration: const Duration( - seconds: 3, - ), - ), - ); - }, - icon: const Icon( - UiIcons.navigation, - size: 14, - ), - label: const Text( - "Get direction", - style: TextStyle(fontSize: 12), - ), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.textPrimary, - side: const BorderSide( - color: UiColors.border, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 20, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 0, - ), - minimumSize: const Size(0, 32), - ), - ), - ], - ), - const SizedBox(height: 12), - Container( - height: 128, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Icon( - UiIcons.mapPin, - color: UiColors.iconSecondary, - size: 32, - ), - ), - // Placeholder for Map - ), - ], - ), - const SizedBox(height: 24), - - // Additional Info - if (widget.shift.description != null) ...[ - SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text( - "ADDITIONAL INFO", - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 8), - Text( - widget.shift.description!.split('.')[0], - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), - ), - Text( - widget.shift.description!, - style: UiTypography.body3r.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - ], - - // Actions - if (!widget.historyMode) - if (status == 'confirmed') - SizedBox( - width: double.infinity, - height: 48, - child: OutlinedButton.icon( - onPressed: widget.onRequestSwap, - icon: const Icon( - UiIcons.swap, - size: 16, - ), - label: Text( - t.staff_shifts.action.request_swap), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.primary, - side: const BorderSide( - color: UiColors.primary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 12, - ), - ), - ), - ), - ) - else if (status == 'swap') - Container( - width: double.infinity, - height: 48, - decoration: BoxDecoration( - color: const Color( - 0xFFFFFBEB, - ), // amber-50 - border: Border.all( - color: const Color(0xFFFDE68A), - ), // amber-200 - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.swap, - size: 16, - color: Color(0xFFB45309), - ), // amber-700 - const SizedBox(width: 8), - Text( - t.staff_shifts.status.swap_requested, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Color(0xFFB45309), - ), - ), - ], - ), - ) - else - Column( - children: [ - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - onPressed: widget.onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(12), - ), - ), - child: Text( - status == 'pending' - ? t.staff_shifts.action.confirm - : "Book Shift", - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - ), - ), - if (status == 'pending' || - status == 'open') ...[ - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 48, - child: OutlinedButton( - onPressed: widget.onDecline, - style: OutlinedButton.styleFrom( - foregroundColor: - UiColors.destructive, - side: const BorderSide( - color: UiColors.border, - ), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(12), - ), - ), - child: Text( - t.staff_shifts.action.decline), - ), - ), - ], - ], - ), - ], - ), - ), - ], - ) - : const SizedBox.shrink(), - ), ], ), ), ); } - - Widget _buildStatCard(IconData icon, String value, String label) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UiColors.border), - ), - child: Column( - children: [ - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: Icon(icon, size: 20, color: UiColors.iconSecondary), - ), - const SizedBox(height: 8), - Text( - value, - style: UiTypography.title1m.copyWith( - color: UiColors.textPrimary, - ), - ), - Text( - label, - style: UiTypography.footnote2r.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - ); - } - - Widget _buildTimeBox(String label, String time) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Text( - label, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 4), - Text( - _formatTime(time), - style: UiTypography.display2m.copyWith( - fontSize: 20, - color: UiColors.textPrimary, - ), - ), - ], - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart new file mode 100644 index 00000000..32bfdcd4 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import '../../styles/shifts_styles.dart'; + +class EmptyStateView extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyStateView({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFFF1F3F5), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 32, color: AppColors.krowMuted), + ), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.krowCharcoal, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle(fontSize: 14, color: AppColors.krowMuted), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 16), + ElevatedButton( + onPressed: onAction, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.krowBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart new file mode 100644 index 00000000..648e9a85 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -0,0 +1,186 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../styles/shifts_styles.dart'; +import '../my_shift_card.dart'; +import '../shared/empty_state_view.dart'; + +class FindShiftsTab extends StatefulWidget { + final List availableJobs; + + const FindShiftsTab({ + super.key, + required this.availableJobs, + }); + + @override + State createState() => _FindShiftsTabState(); +} + +class _FindShiftsTabState extends State { + String _searchQuery = ''; + String _jobType = 'all'; + + Widget _buildFilterTab(String id, String label) { + final isSelected = _jobType == id; + return GestureDetector( + onTap: () => setState(() => _jobType = id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppColors.krowBlue : Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: isSelected ? AppColors.krowBlue : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : const Color(0xFF64748B), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Filter logic + final filteredJobs = widget.availableJobs.where((s) { + final matchesSearch = + s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || + s.location.toLowerCase().contains(_searchQuery.toLowerCase()) || + s.clientName.toLowerCase().contains(_searchQuery.toLowerCase()); + + if (!matchesSearch) return false; + + if (_jobType == 'all') return true; + if (_jobType == 'one-day') { + return s.durationDays == null || s.durationDays! <= 1; + } + if (_jobType == 'multi-day') + return s.durationDays != null && s.durationDays! > 1; + return true; + }).toList(); + + return Column( + children: [ + // Search and Filters + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + children: [ + // Search Bar + Row( + children: [ + Expanded( + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFE2E8F0), + ), + ), + child: Row( + children: [ + const Icon( + UiIcons.search, + size: 20, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + onChanged: (v) => + setState(() => _searchQuery = v), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: "Search jobs, location...", + hintStyle: TextStyle( + color: Color(0xFF94A3B8), + fontSize: 14, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFE2E8F0), + ), + ), + child: const Icon( + UiIcons.filter, + size: 18, + color: Color(0xFF64748B), + ), + ), + ], + ), + const SizedBox(height: 16), + // Filter Tabs + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterTab('all', 'All Jobs'), + const SizedBox(width: 8), + _buildFilterTab('one-day', 'One Day'), + const SizedBox(width: 8), + _buildFilterTab('multi-day', 'Multi-Day'), + const SizedBox(width: 8), + _buildFilterTab('long-term', 'Long Term'), + ], + ), + ), + ], + ), + ), + + Expanded( + child: filteredJobs.isEmpty + ? EmptyStateView( + icon: UiIcons.search, + title: "No jobs available", + subtitle: "Check back later", + ) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 20), + ...filteredJobs.map( + (shift) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MyShiftCard( + shift: shift, + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart new file mode 100644 index 00000000..b89783ba --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../../navigation/shifts_navigator.dart'; +import '../my_shift_card.dart'; +import '../shared/empty_state_view.dart'; + +class HistoryShiftsTab extends StatelessWidget { + final List historyShifts; + + const HistoryShiftsTab({ + super.key, + required this.historyShifts, + }); + + @override + Widget build(BuildContext context) { + if (historyShifts.isEmpty) { + return EmptyStateView( + icon: UiIcons.clock, + title: "No shift history", + subtitle: "Completed shifts appear here", + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 20), + ...historyShifts.map( + (shift) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GestureDetector( + onTap: () => Modular.to.pushShiftDetails(shift), + child: MyShiftCard( + shift: shift, + ), + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart new file mode 100644 index 00000000..ef1a5523 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -0,0 +1,582 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:design_system/design_system.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../blocs/shifts/shifts_bloc.dart'; +import '../my_shift_card.dart'; +import '../shift_assignment_card.dart'; +import '../shared/empty_state_view.dart'; +import '../../styles/shifts_styles.dart'; + +class MyShiftsTab extends StatefulWidget { + final List myShifts; + final List pendingAssignments; + final List cancelledShifts; + + const MyShiftsTab({ + super.key, + required this.myShifts, + required this.pendingAssignments, + required this.cancelledShifts, + }); + + @override + State createState() => _MyShiftsTabState(); +} + +class _MyShiftsTabState extends State { + DateTime _selectedDate = DateTime.now(); + int _weekOffset = 0; + + List _getCalendarDays() { + final now = DateTime.now(); + int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + int daysSinceFriday = (reactDayIndex + 2) % 7; + final start = now + .subtract(Duration(days: daysSinceFriday)) + .add(Duration(days: _weekOffset * 7)); + final startDate = DateTime(start.year, start.month, start.day); + return List.generate(7, (index) => startDate.add(Duration(days: index))); + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + void _confirmShift(String id) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Accept Shift'), + content: const Text( + 'Are you sure you want to accept this shift?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add(AcceptShiftEvent(id)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift confirmed!'), + backgroundColor: Color(0xFF10B981), + ), + ); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF10B981), + ), + child: const Text('Accept'), + ), + ], + ), + ); + } + + void _declineShift(String id) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Decline Shift'), + content: const Text( + 'Are you sure you want to decline this shift? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add(DeclineShiftEvent(id)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift declined.'), + backgroundColor: Color(0xFFEF4444), + ), + ); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFEF4444), + ), + child: const Text('Decline'), + ), + ], + ), + ); + } + + String _formatDateStr(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + if (_isSameDay(date, now)) return "Today"; + final tomorrow = now.add(const Duration(days: 1)); + if (_isSameDay(date, tomorrow)) return "Tomorrow"; + return DateFormat('EEE, MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + @override + Widget build(BuildContext context) { + final calendarDays = _getCalendarDays(); + final weekStartDate = calendarDays.first; + final weekEndDate = calendarDays.last; + + final visibleMyShifts = widget.myShifts.where((s) { + try { + final date = DateTime.parse(s.date); + return date.isAfter( + weekStartDate.subtract(const Duration(seconds: 1)), + ) && + date.isBefore(weekEndDate.add(const Duration(days: 1))); + } catch (_) { + return false; + } + }).toList(); + + final visibleCancelledShifts = widget.cancelledShifts.where((s) { + try { + final date = DateTime.parse(s.date); + return date.isAfter( + weekStartDate.subtract(const Duration(seconds: 1)), + ) && + date.isBefore(weekEndDate.add(const Duration(days: 1))); + } catch (_) { + return false; + } + }).toList(); + + return Column( + children: [ + // Calendar Selector + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: AppColors.krowCharcoal, + ), + onPressed: () => setState(() => _weekOffset--), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + Text( + DateFormat('MMMM yyyy').format(weekStartDate), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.krowCharcoal, + ), + ), + IconButton( + icon: const Icon( + UiIcons.chevronRight, + size: 20, + color: AppColors.krowCharcoal, + ), + onPressed: () => setState(() => _weekOffset++), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + ), + // Days Grid + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: calendarDays.map((date) { + final isSelected = _isSameDay(date, _selectedDate); + // ignore: unused_local_variable + final dateStr = DateFormat('yyyy-MM-dd').format(date); + final hasShifts = widget.myShifts.any((s) { + try { + return _isSameDay(DateTime.parse(s.date), date); + } catch (_) { + return false; + } + }); + + return GestureDetector( + onTap: () => setState(() => _selectedDate = date), + child: Column( + children: [ + Container( + width: 44, + height: 60, + decoration: BoxDecoration( + color: isSelected ? AppColors.krowBlue : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.krowBlue + : AppColors.krowBorder, + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.white + : AppColors.krowCharcoal, + ), + ), + Text( + DateFormat('E').format(date), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: isSelected + ? Colors.white.withOpacity(0.8) + : AppColors.krowMuted, + ), + ), + if (hasShifts && !isSelected) + Container( + margin: const EdgeInsets.only(top: 4), + width: 4, + height: 4, + decoration: const BoxDecoration( + color: AppColors.krowBlue, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.krowBorder), + + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 20), + if (widget.pendingAssignments.isNotEmpty) ...[ + _buildSectionHeader( + "Awaiting Confirmation", + const Color(0xFFF59E0B), + ), + ...widget.pendingAssignments.map( + (shift) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: ShiftAssignmentCard( + shift: shift, + onConfirm: () => _confirmShift(shift.id), + onDecline: () => _declineShift(shift.id), + isConfirming: true, + ), + ), + ), + const SizedBox(height: 12), + ], + + if (visibleCancelledShifts.isNotEmpty) ...[ + _buildSectionHeader( + "Cancelled Shifts", + AppColors.krowMuted, + ), + ...visibleCancelledShifts.map( + (shift) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _buildCancelledCard( + title: shift.title, + client: shift.clientName, + pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}", + rate: "\$${shift.hourlyRate}/hr · 8h", + date: _formatDateStr(shift.date), + time: "${shift.startTime} - ${shift.endTime}", + address: shift.locationAddress, + isLastMinute: true, + onTap: () {}, + ), + ), + ), + const SizedBox(height: 12), + ], + + // Confirmed Shifts + if (visibleMyShifts.isNotEmpty) ...[ + _buildSectionHeader( + "Confirmed Shifts", + AppColors.krowMuted, + ), + ...visibleMyShifts.map( + (shift) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MyShiftCard(shift: shift), + ), + ), + ], + + if (visibleMyShifts.isEmpty && + widget.pendingAssignments.isEmpty && + widget.cancelledShifts.isEmpty) + const EmptyStateView( + icon: UiIcons.calendar, + title: "No shifts this week", + subtitle: "Try finding new jobs in the Find tab", + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + } + + Widget _buildSectionHeader(String title, Color dotColor) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: dotColor == AppColors.krowMuted + ? AppColors.krowMuted + : dotColor, + ), + ), + ], + ), + ); + } + + + + Widget _buildCancelledCard({ + required String title, + required String client, + required String pay, + required String rate, + required String date, + required String time, + required String address, + required bool isLastMinute, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.krowBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFFEF4444), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + "CANCELLED", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFFEF4444), + ), + ), + if (isLastMinute) ...[ + const SizedBox(width: 4), + const Text( + "• 4hr compensation", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Color(0xFF10B981), + ), + ), + ], + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.krowBlue.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + LucideIcons.briefcase, + color: AppColors.krowBlue, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.krowCharcoal, + ), + ), + Text( + client, + style: const TextStyle( + fontSize: 12, + color: AppColors.krowMuted, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + pay, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.krowCharcoal, + ), + ), + Text( + rate, + style: const TextStyle( + fontSize: 10, + color: AppColors.krowMuted, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + LucideIcons.calendar, + size: 12, + color: AppColors.krowMuted, + ), + const SizedBox(width: 4), + Text( + date, + style: const TextStyle( + fontSize: 12, + color: AppColors.krowMuted, + ), + ), + const SizedBox(width: 12), + const Icon( + LucideIcons.clock, + size: 12, + color: AppColors.krowMuted, + ), + const SizedBox(width: 4), + Text( + time, + style: const TextStyle( + fontSize: 12, + color: AppColors.krowMuted, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + LucideIcons.mapPin, + size: 12, + color: AppColors.krowMuted, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + address, + style: const TextStyle( + fontSize: 12, + color: AppColors.krowMuted, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart new file mode 100644 index 00000000..78fddf80 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -0,0 +1,31 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'domain/repositories/shifts_repository_interface.dart'; +import 'data/repositories_impl/shifts_repository_impl.dart'; +import 'domain/usecases/get_shift_details_usecase.dart'; +import 'domain/usecases/accept_shift_usecase.dart'; +import 'domain/usecases/decline_shift_usecase.dart'; +import 'domain/usecases/apply_for_shift_usecase.dart'; +import 'presentation/blocs/shift_details/shift_details_bloc.dart'; +import 'presentation/pages/shift_details_page.dart'; + +class ShiftDetailsModule extends Module { + @override + void binds(Injector i) { + // Repository + i.add(ShiftsRepositoryImpl.new); + + // UseCases + i.add(GetShiftDetailsUseCase.new); + i.add(AcceptShiftUseCase.new); + i.add(DeclineShiftUseCase.new); + i.add(ApplyForShiftUseCase.new); + + // Bloc + i.add(ShiftDetailsBloc.new); + } + + @override + void routes(RouteManager r) { + r.child('/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data)); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 8b979692..a1adddc4 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -8,8 +8,10 @@ import 'domain/usecases/get_cancelled_shifts_usecase.dart'; import 'domain/usecases/get_history_shifts_usecase.dart'; import 'domain/usecases/accept_shift_usecase.dart'; import 'domain/usecases/decline_shift_usecase.dart'; +import 'domain/usecases/apply_for_shift_usecase.dart'; import 'domain/usecases/get_shift_details_usecase.dart'; import 'presentation/blocs/shifts/shifts_bloc.dart'; +import 'presentation/blocs/shift_details/shift_details_bloc.dart'; import 'presentation/pages/shifts_page.dart'; import 'presentation/pages/shift_details_page.dart'; @@ -27,15 +29,16 @@ class StaffShiftsModule extends Module { i.add(GetHistoryShiftsUseCase.new); i.add(AcceptShiftUseCase.new); i.add(DeclineShiftUseCase.new); + i.add(ApplyForShiftUseCase.new); i.add(GetShiftDetailsUseCase.new); // Bloc i.add(ShiftsBloc.new); + i.add(ShiftDetailsBloc.new); } @override void routes(RouteManager r) { r.child('/', child: (_) => const ShiftsPage()); - r.child('/details/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data)); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart index 28ae0ac4..7d0a0518 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart @@ -1,4 +1,6 @@ library staff_shifts; export 'src/staff_shifts_module.dart'; +export 'src/shift_details_module.dart'; +export 'src/presentation/navigation/shifts_navigator.dart'; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index d7f5e3e0..661aa05d 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -77,6 +77,10 @@ class StaffMainModule extends Module { '/availability', module: StaffAvailabilityModule(), ); + r.module( + '/shift-details', + module: ShiftDetailsModule(), + ); } }