Merge pull request #344 from Oloodi/312-feature-integrate-google-maps-places-autocomplete-for-hub-address-validation

Continuation of the development of the mobile apps
This commit is contained in:
Achintha Isuru
2026-02-01 03:50:22 -05:00
committed by GitHub
49 changed files with 2045 additions and 1959 deletions

View File

@@ -7,7 +7,7 @@ plugins {
} }
android { android {
namespace = "com.example.krow_client" namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion

View File

@@ -1,4 +1,4 @@
package com.example.krow_client package com.krowwithus.client
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "krow_client") set(BINARY_NAME "krow_client")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -7,7 +7,7 @@ plugins {
} }
android { android {
namespace = "com.example.krow_staff" namespace = "com.krowwithus.staff"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion

View File

@@ -193,6 +193,14 @@
} }
}, },
"oauth_client": [ "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_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3 "client_type": 3

View File

@@ -1,4 +1,4 @@
package com.example.krow_staff package com.krowwithus.staff
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "krow_staff") set(BINARY_NAME "krow_staff")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -2,3 +2,4 @@ library core;
export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart'; export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart';

View File

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

View File

@@ -24,6 +24,8 @@ class Shift extends Equatable {
final double? longitude; final double? longitude;
final String? status; final String? status;
final int? durationDays; // For multi-day shifts final int? durationDays; // For multi-day shifts
final int? requiredSlots;
final int? filledSlots;
const Shift({ const Shift({
required this.id, required this.id,
@@ -49,6 +51,8 @@ class Shift extends Equatable {
this.longitude, this.longitude,
this.status, this.status,
this.durationDays, this.durationDays,
this.requiredSlots,
this.filledSlots,
}); });
@override @override
@@ -76,6 +80,8 @@ class Shift extends Equatable {
longitude, longitude,
status, status,
durationDays, durationDays,
requiredSlots,
filledSlots,
]; ];
} }

View File

@@ -3,7 +3,6 @@ import 'dart:developer' as developer;
import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; 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' import 'package:krow_domain/krow_domain.dart'
show show
InvalidCredentialsException, InvalidCredentialsException,
@@ -15,6 +14,7 @@ import 'package:krow_domain/krow_domain.dart'
UnauthorizedAppException, UnauthorizedAppException,
PasswordMismatchException, PasswordMismatchException,
GoogleOnlyAccountException; GoogleOnlyAccountException;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/auth_repository_interface.dart'; import '../../domain/repositories/auth_repository_interface.dart';

View File

@@ -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<void> submitProfile({
required String fullName,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,
required List<String> industries,
required List<String> 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<CreateStaffData, CreateStaffVariables>
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),
);
}
}
}

View File

@@ -0,0 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
abstract class ProfileSetupRepository {
Future<void> submitProfile({
required String fullName,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,
required List<String> industries,
required List<String> skills,
});
}

View File

@@ -0,0 +1,25 @@
import '../repositories/profile_setup_repository.dart';
class SubmitProfileSetup {
final ProfileSetupRepository repository;
SubmitProfileSetup(this.repository);
Future<void> call({
required String fullName,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,
required List<String> industries,
required List<String> skills,
}) {
return repository.submitProfile(
fullName: fullName,
bio: bio,
preferredLocations: preferredLocations,
maxDistanceMiles: maxDistanceMiles,
industries: industries,
skills: skills,
);
}
}

View File

@@ -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:flutter_bloc/flutter_bloc.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import '../../../domain/usecases/submit_profile_setup_usecase.dart';
import 'package:krow_domain/krow_domain.dart';
import 'profile_setup_event.dart'; import 'profile_setup_event.dart';
import 'profile_setup_state.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. /// BLoC responsible for managing the profile setup state and logic.
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> { class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
ProfileSetupBloc({ ProfileSetupBloc({
required auth.FirebaseAuth firebaseAuth, required SubmitProfileSetup submitProfileSetup,
required dc.ExampleConnector dataConnect, }) : _submitProfileSetup = submitProfileSetup,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect,
super(const ProfileSetupState()) { super(const ProfileSetupState()) {
on<ProfileSetupFullNameChanged>(_onFullNameChanged); on<ProfileSetupFullNameChanged>(_onFullNameChanged);
on<ProfileSetupBioChanged>(_onBioChanged); on<ProfileSetupBioChanged>(_onBioChanged);
@@ -27,8 +22,7 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
on<ProfileSetupSubmitted>(_onSubmitted); on<ProfileSetupSubmitted>(_onSubmitted);
} }
final auth.FirebaseAuth _firebaseAuth; final SubmitProfileSetup _submitProfileSetup;
final dc.ExampleConnector _dataConnect;
/// Handles the [ProfileSetupFullNameChanged] event. /// Handles the [ProfileSetupFullNameChanged] event.
void _onFullNameChanged( void _onFullNameChanged(
@@ -86,44 +80,14 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
emit(state.copyWith(status: ProfileSetupStatus.loading)); emit(state.copyWith(status: ProfileSetupStatus.loading));
try { try {
final auth.User? firebaseUser = _firebaseAuth.currentUser; await _submitProfileSetup(
if (firebaseUser == null) { fullName: state.fullName,
throw Exception('User not authenticated.'); bio: state.bio.isEmpty ? null : state.bio,
} preferredLocations: state.preferredLocations,
maxDistanceMiles: state.maxDistanceMiles,
final dc.StaffSession? session = dc.StaffSessionStore.instance.session; industries: state.industries,
final String email = session?.user.email ?? ''; skills: state.skills,
final String? phone = firebaseUser.phoneNumber;
final fdc.OperationResult<dc.CreateStaffData, dc.CreateStaffVariables>
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,
); );
if (session != null) {
dc.StaffSessionStore.instance.setSession(
dc.StaffSession(user: session.user, staff: staff),
);
}
emit(state.copyWith(status: ProfileSetupStatus.success)); emit(state.copyWith(status: ProfileSetupStatus.success));
} catch (e) { } catch (e) {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
class GetStartedActions extends StatelessWidget { class GetStartedActions extends StatelessWidget {
final VoidCallback onSignUpPressed; final VoidCallback onSignUpPressed;
@@ -13,40 +14,15 @@ class GetStartedActions extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffAuthenticationGetStartedPageEn i18n =
t.staff_authentication.get_started_page;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ spacing: UiConstants.space4,
ElevatedButton( children: <Widget>[
onPressed: onSignUpPressed, UiButton.primary(onPressed: onSignUpPressed, text: i18n.sign_up_button),
style: ElevatedButton.styleFrom( UiButton.secondary(onPressed: onLoginPressed, text: i18n.log_in_button),
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),
),
),
], ],
); );
} }

View File

@@ -1,26 +1,42 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.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 { class GetStartedHeader extends StatelessWidget {
/// Creates a [GetStartedHeader].
const GetStartedHeader({super.key}); const GetStartedHeader({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffAuthenticationGetStartedPageEn i18n =
t.staff_authentication.get_started_page;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: <Widget>[
Text( RichText(
'Krow Workforce',
style: UiTypography.display1b.copyWith(color: UiColors.textPrimary),
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(
style: UiTypography.displayM,
children: <InlineSpan>[
TextSpan(
text: i18n.title_part1,
),
TextSpan(
text: i18n.title_part2,
style: UiTypography.displayMb.textLink,
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Find flexible shifts that fit your schedule.', i18n.subtitle,
style: UiTypography.body1r.copyWith(color: UiColors.textSecondary),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body1r.textSecondary,
), ),
], ],
); );
} }
} }

View File

@@ -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/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/sign_in_with_phone_usecase.dart';
import 'package:staff_authentication/src/domain/usecases/verify_otp_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/auth_bloc.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_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'; import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
@@ -35,10 +38,17 @@ class StaffAuthenticationModule extends Module {
dataConnect: ExampleConnector.instance, dataConnect: ExampleConnector.instance,
), ),
); );
i.addLazySingleton<ProfileSetupRepository>(
() => ProfileSetupRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
// UseCases // UseCases
i.addLazySingleton(SignInWithPhoneUseCase.new); i.addLazySingleton(SignInWithPhoneUseCase.new);
i.addLazySingleton(VerifyOtpUseCase.new); i.addLazySingleton(VerifyOtpUseCase.new);
i.addLazySingleton(SubmitProfileSetup.new);
// BLoCs // BLoCs
i.addLazySingleton<AuthBloc>( i.addLazySingleton<AuthBloc>(
@@ -49,8 +59,7 @@ class StaffAuthenticationModule extends Module {
); );
i.add<ProfileSetupBloc>( i.add<ProfileSetupBloc>(
() => ProfileSetupBloc( () => ProfileSetupBloc(
firebaseAuth: firebase.FirebaseAuth.instance, submitProfileSetup: i.get<SubmitProfileSetup>(),
dataConnect: ExampleConnector.instance,
), ),
); );
} }

View File

@@ -1,16 +1,16 @@
import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.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'; import 'package:staff_home/src/domain/repositories/home_repository.dart';
extension TimestampExt on Timestamp { extension TimestampExt on Timestamp {
DateTime toDate() { DateTime toDate() {
return DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000 + nanoseconds ~/ 1000000); return DateTimeUtils.toDeviceTime(toDateTime());
} }
} }
class HomeRepositoryImpl implements HomeRepository { class HomeRepositoryImpl implements HomeRepository {
HomeRepositoryImpl(); HomeRepositoryImpl();
@@ -63,7 +63,15 @@ class HomeRepositoryImpl implements HomeRepository {
final response = await ExampleConnector.instance.listShifts().execute(); final response = await ExampleConnector.instance.listShifts().execute();
return response.data.shifts 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) .take(10)
.map((s) => _mapConnectorShiftToDomain(s)) .map((s) => _mapConnectorShiftToDomain(s))
.toList(); .toList();
@@ -72,6 +80,12 @@ class HomeRepositoryImpl implements HomeRepository {
} }
} }
@override
Future<String?> getStaffName() async {
final session = StaffSessionStore.instance.session;
return session?.staff?.name;
}
// Mappers specific to Home's Domain Entity 'Shift' // Mappers specific to Home's Domain Entity 'Shift'
// Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift. // Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift.

View File

@@ -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. /// Repository interface for home screen data operations.
/// ///
@@ -14,4 +14,7 @@ abstract class HomeRepository {
/// Retrieves shifts recommended for the worker based on their profile. /// Retrieves shifts recommended for the worker based on their profile.
Future<List<Shift>> getRecommendedShifts(); Future<List<Shift>> getRecommendedShifts();
/// Retrieves the current staff member's name.
Future<String?> getStaffName();
} }

View File

@@ -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'; import 'package:staff_home/src/domain/repositories/home_repository.dart';
/// Use case for fetching all shifts displayed on the home screen. /// Use case for fetching all shifts displayed on the home screen.

View File

@@ -1,7 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_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/usecases/get_home_shifts.dart';
import 'package:staff_home/src/domain/repositories/home_repository.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). /// Simple Cubit to manage home page state (shifts + loading/error).
class HomeCubit extends Cubit<HomeState> { class HomeCubit extends Cubit<HomeState> {
final GetHomeShifts _getHomeShifts; final GetHomeShifts _getHomeShifts;
final HomeRepository _repository;
HomeCubit(HomeRepository repository) HomeCubit(HomeRepository repository)
: _getHomeShifts = GetHomeShifts(repository), : _getHomeShifts = GetHomeShifts(repository),
_repository = repository,
super(const HomeState.initial()); super(const HomeState.initial());
Future<void> loadShifts() async { Future<void> loadShifts() async {
@@ -20,6 +22,7 @@ class HomeCubit extends Cubit<HomeState> {
emit(state.copyWith(status: HomeStatus.loading)); emit(state.copyWith(status: HomeStatus.loading));
try { try {
final result = await _getHomeShifts.call(); final result = await _getHomeShifts.call();
final name = await _repository.getStaffName();
if (isClosed) return; if (isClosed) return;
emit( emit(
state.copyWith( state.copyWith(
@@ -27,6 +30,7 @@ class HomeCubit extends Cubit<HomeState> {
todayShifts: result.today, todayShifts: result.today,
tomorrowShifts: result.tomorrow, tomorrowShifts: result.tomorrow,
recommendedShifts: result.recommended, recommendedShifts: result.recommended,
staffName: name,
// Mock profile status for now, ideally fetched from a user repository // Mock profile status for now, ideally fetched from a user repository
isProfileComplete: false, isProfileComplete: false,
), ),

View File

@@ -9,6 +9,7 @@ class HomeState extends Equatable {
final List<Shift> recommendedShifts; final List<Shift> recommendedShifts;
final bool autoMatchEnabled; final bool autoMatchEnabled;
final bool isProfileComplete; final bool isProfileComplete;
final String? staffName;
final String? errorMessage; final String? errorMessage;
const HomeState({ const HomeState({
@@ -18,6 +19,7 @@ class HomeState extends Equatable {
this.recommendedShifts = const [], this.recommendedShifts = const [],
this.autoMatchEnabled = false, this.autoMatchEnabled = false,
this.isProfileComplete = false, this.isProfileComplete = false,
this.staffName,
this.errorMessage, this.errorMessage,
}); });
@@ -30,6 +32,7 @@ class HomeState extends Equatable {
List<Shift>? recommendedShifts, List<Shift>? recommendedShifts,
bool? autoMatchEnabled, bool? autoMatchEnabled,
bool? isProfileComplete, bool? isProfileComplete,
String? staffName,
String? errorMessage, String? errorMessage,
}) { }) {
return HomeState( return HomeState(
@@ -39,18 +42,20 @@ class HomeState extends Equatable {
recommendedShifts: recommendedShifts ?? this.recommendedShifts, recommendedShifts: recommendedShifts ?? this.recommendedShifts,
autoMatchEnabled: autoMatchEnabled ?? this.autoMatchEnabled, autoMatchEnabled: autoMatchEnabled ?? this.autoMatchEnabled,
isProfileComplete: isProfileComplete ?? this.isProfileComplete, isProfileComplete: isProfileComplete ?? this.isProfileComplete,
staffName: staffName ?? this.staffName,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
status, status,
todayShifts, todayShifts,
tomorrowShifts, tomorrowShifts,
recommendedShifts, recommendedShifts,
autoMatchEnabled, autoMatchEnabled,
isProfileComplete, isProfileComplete,
errorMessage, staffName,
]; errorMessage,
} ];
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
/// Extension on [IModularNavigator] providing typed navigation helpers /// Extension on [IModularNavigator] providing typed navigation helpers
/// for the Staff Home feature (worker home screen). /// for the Staff Home feature (worker home screen).
@@ -40,4 +41,9 @@ extension HomeNavigator on IModularNavigator {
void pushSettings() { void pushSettings() {
pushNamed('/settings'); 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);
}
} }

View File

@@ -48,7 +48,12 @@ class WorkerHomePage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const HomeHeader(), BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) => previous.staffName != current.staffName,
builder: (context, state) {
return HomeHeader(userName: state.staffName);
},
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
child: Column( child: Column(

View File

@@ -2,15 +2,21 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Header widget for the staff home page, using design system tokens. /// Header widget for the staff home page, using design system tokens.
class HomeHeader extends StatelessWidget { class HomeHeader extends StatelessWidget {
final String? userName;
/// Creates a [HomeHeader]. /// Creates a [HomeHeader].
const HomeHeader({super.key}); const HomeHeader({super.key, this.userName});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final headerI18n = t.staff.home.header; final headerI18n = t.staff.home.header;
final nameToDisplay = userName ?? headerI18n.user_name_placeholder;
final initial = nameToDisplay.isNotEmpty
? nameToDisplay[0].toUpperCase()
: 'K';
return Padding( return Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
UiConstants.space4, UiConstants.space4,
@@ -18,45 +24,42 @@ class HomeHeader extends StatelessWidget {
UiConstants.space4, UiConstants.space4,
UiConstants.space3, 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: [ children: [
Container( Text(
width: 48, headerI18n.welcome_back,
height: 48, style: UiTypography.body3r.textSecondary,
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(nameToDisplay, style: UiTypography.headline4m),
], ],
), ),
],
),
); );
} }
} }

View File

@@ -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: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 { class RecommendedShiftCard extends StatelessWidget {
final Shift shift; final Shift shift;
@@ -18,13 +19,7 @@ class RecommendedShiftCard extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
ScaffoldMessenger.of(context).showSnackBar( Modular.to.pushShiftDetails(shift);
SnackBar(
content: Text(recI18n.applied_for(title: shift.title)),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}, },
child: Container( child: Container(
width: 300, width: 300,

View File

@@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:design_system/design_system.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 { class ShiftCard extends StatefulWidget {
final Shift shift; final Shift shift;

View File

@@ -28,6 +28,8 @@ dependencies:
path: ../../../core path: ../../../core
krow_domain: krow_domain:
path: ../../../domain path: ../../../domain
staff_shifts:
path: ../shifts
krow_data_connect: krow_data_connect:
path: ../../../data_connect path: ../../../data_connect
firebase_data_connect: firebase_data_connect:

View File

@@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_core/core.dart';
import '../../domain/repositories/shifts_repository_interface.dart'; import '../../domain/repositories/shifts_repository_interface.dart';
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
@@ -51,27 +52,26 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
DateTime? _toDateTime(dynamic t) { DateTime? _toDateTime(dynamic t) {
if (t == null) return null; if (t == null) return null;
if (t is DateTime) return t; DateTime? dt;
if (t is String) return DateTime.tryParse(t); if (t is Timestamp) {
dt = t.toDateTime();
// Data Connect Timestamp handling } else if (t is String) {
try { dt = DateTime.tryParse(t);
if (t is Timestamp) { } else {
return t.toDateTime(); 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; return null;
} }
@@ -127,6 +127,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: _mapStatus(status), status: _mapStatus(status),
description: shift.description, description: shift.description,
durationDays: shift.durationDays, durationDays: shift.durationDays,
requiredSlots: shift.requiredSlots,
filledSlots: shift.filledSlots,
)); ));
} }
} }
@@ -182,6 +184,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue.toLowerCase() ?? 'open', status: s.status?.stringValue.toLowerCase() ?? 'open',
description: s.description, description: s.description,
durationDays: s.durationDays, 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; final s = result.data.shift;
if (s == null) return null; 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 startDt = _toDateTime(s.startTime);
final endDt = _toDateTime(s.endTime); final endDt = _toDateTime(s.endTime);
final createdDt = _toDateTime(s.createdAt); final createdDt = _toDateTime(s.createdAt);
@@ -229,6 +247,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue ?? 'OPEN', status: s.status?.stringValue ?? 'OPEN',
description: s.description, description: s.description,
durationDays: s.durationDays, durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
); );
} catch (e) { } catch (e) {
return null; return null;
@@ -236,7 +256,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
} }
@override @override
Future<void> applyForShift(String shiftId) async { Future<void> applyForShift(String shiftId, {bool isInstantBook = false}) async {
final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute(); final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift'); if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift');
@@ -246,8 +266,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
await _dataConnect.createApplication( await _dataConnect.createApplication(
shiftId: shiftId, shiftId: shiftId,
staffId: staffId, staffId: staffId,
roleId: role.id, roleId: role.roleId,
status: dc.ApplicationStatus.PENDING, status: isInstantBook ? dc.ApplicationStatus.ACCEPTED : dc.ApplicationStatus.PENDING,
origin: dc.ApplicationOrigin.STAFF, origin: dc.ApplicationOrigin.STAFF,
).execute(); ).execute();
} }
@@ -286,6 +306,22 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
} }
if (appId == null || roleId == null) { 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"); throw Exception("Application not found for shift $shiftId");
} }

View File

@@ -18,7 +18,9 @@ abstract interface class ShiftsRepositoryInterface {
Future<Shift?> getShiftDetails(String shiftId); Future<Shift?> getShiftDetails(String shiftId);
/// Applies for a specific open shift. /// Applies for a specific open shift.
Future<void> applyForShift(String shiftId); ///
/// [isInstantBook] determines if the application should be immediately accepted.
Future<void> applyForShift(String shiftId, {bool isInstantBook = false});
/// Accepts a pending shift assignment. /// Accepts a pending shift assignment.
Future<void> acceptShift(String shiftId); Future<void> acceptShift(String shiftId);

View File

@@ -0,0 +1,11 @@
import '../repositories/shifts_repository_interface.dart';
class ApplyForShiftUseCase {
final ShiftsRepositoryInterface repository;
ApplyForShiftUseCase(this.repository);
Future<void> call(String shiftId, {bool isInstantBook = false}) async {
return repository.applyForShift(shiftId, isInstantBook: isInstantBook);
}
}

View File

@@ -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<ShiftDetailsEvent, ShiftDetailsState> {
final GetShiftDetailsUseCase getShiftDetails;
final ApplyForShiftUseCase applyForShift;
final DeclineShiftUseCase declineShift;
ShiftDetailsBloc({
required this.getShiftDetails,
required this.applyForShift,
required this.declineShift,
}) : super(ShiftDetailsInitial()) {
on<LoadShiftDetailsEvent>(_onLoadDetails);
on<BookShiftDetailsEvent>(_onBookShift);
on<DeclineShiftDetailsEvent>(_onDeclineShift);
}
Future<void> _onLoadDetails(
LoadShiftDetailsEvent event,
Emitter<ShiftDetailsState> 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<void> _onBookShift(
BookShiftDetailsEvent event,
Emitter<ShiftDetailsState> emit,
) async {
try {
await applyForShift(event.shiftId, isInstantBook: true);
emit(const ShiftActionSuccess("Shift successfully booked!"));
} catch (e) {
emit(ShiftDetailsError(e.toString()));
}
}
Future<void> _onDeclineShift(
DeclineShiftDetailsEvent event,
Emitter<ShiftDetailsState> emit,
) async {
try {
await declineShift(event.shiftId);
emit(const ShiftActionSuccess("Shift declined"));
} catch (e) {
emit(ShiftDetailsError(e.toString()));
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
abstract class ShiftDetailsEvent extends Equatable {
const ShiftDetailsEvent();
@override
List<Object?> get props => [];
}
class LoadShiftDetailsEvent extends ShiftDetailsEvent {
final String shiftId;
const LoadShiftDetailsEvent(this.shiftId);
@override
List<Object?> get props => [shiftId];
}
class BookShiftDetailsEvent extends ShiftDetailsEvent {
final String shiftId;
const BookShiftDetailsEvent(this.shiftId);
@override
List<Object?> get props => [shiftId];
}
class DeclineShiftDetailsEvent extends ShiftDetailsEvent {
final String shiftId;
const DeclineShiftDetailsEvent(this.shiftId);
@override
List<Object?> get props => [shiftId];
}

View File

@@ -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<Object?> get props => [];
}
class ShiftDetailsInitial extends ShiftDetailsState {}
class ShiftDetailsLoading extends ShiftDetailsState {}
class ShiftDetailsLoaded extends ShiftDetailsState {
final Shift shift;
const ShiftDetailsLoaded(this.shift);
@override
List<Object?> get props => [shift];
}
class ShiftDetailsError extends ShiftDetailsState {
final String message;
const ShiftDetailsError(this.message);
@override
List<Object?> get props => [message];
}
class ShiftActionSuccess extends ShiftDetailsState {
final String message;
const ShiftActionSuccess(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -3,14 +3,12 @@ import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import '../../../domain/usecases/get_available_shifts_usecase.dart';
import '../../../domain/arguments/get_available_shifts_arguments.dart'; import '../../../domain/arguments/get_available_shifts_arguments.dart';
import '../../../domain/usecases/get_my_shifts_usecase.dart'; import '../../../domain/usecases/get_available_shifts_usecase.dart';
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
import '../../../domain/usecases/get_cancelled_shifts_usecase.dart'; import '../../../domain/usecases/get_cancelled_shifts_usecase.dart';
import '../../../domain/usecases/get_history_shifts_usecase.dart'; import '../../../domain/usecases/get_history_shifts_usecase.dart';
import '../../../domain/usecases/accept_shift_usecase.dart'; import '../../../domain/usecases/get_my_shifts_usecase.dart';
import '../../../domain/usecases/decline_shift_usecase.dart'; import '../../../domain/usecases/get_pending_assignments_usecase.dart';
part 'shifts_event.dart'; part 'shifts_event.dart';
part 'shifts_state.dart'; part 'shifts_state.dart';
@@ -21,8 +19,6 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
final GetPendingAssignmentsUseCase getPendingAssignments; final GetPendingAssignmentsUseCase getPendingAssignments;
final GetCancelledShiftsUseCase getCancelledShifts; final GetCancelledShiftsUseCase getCancelledShifts;
final GetHistoryShiftsUseCase getHistoryShifts; final GetHistoryShiftsUseCase getHistoryShifts;
final AcceptShiftUseCase acceptShift;
final DeclineShiftUseCase declineShift;
ShiftsBloc({ ShiftsBloc({
required this.getMyShifts, required this.getMyShifts,
@@ -30,13 +26,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
required this.getPendingAssignments, required this.getPendingAssignments,
required this.getCancelledShifts, required this.getCancelledShifts,
required this.getHistoryShifts, required this.getHistoryShifts,
required this.acceptShift,
required this.declineShift,
}) : super(ShiftsInitial()) { }) : super(ShiftsInitial()) {
on<LoadShiftsEvent>(_onLoadShifts); on<LoadShiftsEvent>(_onLoadShifts);
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts); on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
on<AcceptShiftEvent>(_onAcceptShift);
on<DeclineShiftEvent>(_onDeclineShift);
} }
Future<void> _onLoadShifts( Future<void> _onLoadShifts(
@@ -63,7 +55,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
myShifts: myShiftsResult, myShifts: myShiftsResult,
pendingShifts: pendingResult, pendingShifts: pendingResult,
cancelledShifts: cancelledResult, cancelledShifts: cancelledResult,
availableShifts: availableResult, availableShifts: _filterPastShifts(availableResult),
historyShifts: historyResult, historyShifts: historyResult,
searchQuery: '', searchQuery: '',
jobType: 'all', jobType: 'all',
@@ -89,7 +81,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
)); ));
emit(currentState.copyWith( emit(currentState.copyWith(
availableShifts: result, availableShifts: _filterPastShifts(result),
searchQuery: event.query ?? currentState.searchQuery, searchQuery: event.query ?? currentState.searchQuery,
jobType: event.jobType ?? currentState.jobType, jobType: event.jobType ?? currentState.jobType,
)); ));
@@ -99,27 +91,16 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
} }
} }
Future<void> _onAcceptShift( List<Shift> _filterPastShifts(List<Shift> shifts) {
AcceptShiftEvent event, final now = DateTime.now();
Emitter<ShiftsState> emit, return shifts.where((shift) {
) async { if (shift.date.isEmpty) return false;
try { try {
await acceptShift(event.shiftId); final shiftDate = DateTime.parse(shift.date);
add(LoadShiftsEvent()); // Reload lists return shiftDate.isAfter(now);
} catch (_) { } catch (_) {
// Handle error return false;
} }
} }).toList();
Future<void> _onDeclineShift(
DeclineShiftEvent event,
Emitter<ShiftsState> emit,
) async {
try {
await declineShift(event.shiftId);
add(LoadShiftsEvent()); // Reload lists
} catch (_) {
// Handle error
}
} }
} }

View File

@@ -3,8 +3,6 @@ import 'package:krow_domain/krow_domain.dart';
extension ShiftsNavigator on IModularNavigator { extension ShiftsNavigator on IModularNavigator {
void pushShiftDetails(Shift shift) { 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
} }

View File

@@ -1,25 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/shifts/shifts_bloc.dart'; import '../blocs/shifts/shifts_bloc.dart';
import '../widgets/my_shift_card.dart'; import '../widgets/tabs/my_shifts_tab.dart';
import '../widgets/shift_assignment_card.dart'; import '../widgets/tabs/find_shifts_tab.dart';
import '../widgets/tabs/history_shifts_tab.dart';
// Shim to match POC styles locally import '../styles/shifts_styles.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;
}
class ShiftsPage extends StatefulWidget { class ShiftsPage extends StatefulWidget {
final String? initialTab; final String? initialTab;
@@ -31,15 +19,6 @@ class ShiftsPage extends StatefulWidget {
class _ShiftsPageState extends State<ShiftsPage> { class _ShiftsPageState extends State<ShiftsPage> {
late String _activeTab; 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<ShiftsBloc>(); final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
@override @override
@@ -59,93 +38,30 @@ class _ShiftsPageState extends State<ShiftsPage> {
} }
} }
List<DateTime> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: BlocBuilder<ShiftsBloc, ShiftsState>( child: BlocBuilder<ShiftsBloc, ShiftsState>(
builder: (context, state) { builder: (context, state) {
final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : []; final List<Shift> myShifts = (state is ShiftsLoaded)
final List<Shift> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : []; ? state.myShifts
final List<Shift> pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : []; : [];
final List<Shift> cancelledShifts = (state is ShiftsLoaded) ? state.cancelledShifts : []; final List<Shift> availableJobs = (state is ShiftsLoaded)
final List<Shift> historyShifts = (state is ShiftsLoaded) ? state.historyShifts : []; ? state.availableShifts
: [];
final List<Shift> pendingAssignments = (state is ShiftsLoaded)
? state.pendingShifts
: [];
final List<Shift> cancelledShifts = (state is ShiftsLoaded)
? state.cancelledShifts
: [];
final List<Shift> historyShifts = (state is ShiftsLoaded)
? state.historyShifts
: [];
// Filter logic // Note: "filteredJobs" logic moved to FindShiftsTab
final filteredJobs = availableJobs.where((s) { // Note: Calendar logic moved to MyShiftsTab
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();
return Scaffold( return Scaffold(
backgroundColor: AppColors.krowBackground, backgroundColor: AppColors.krowBackground,
@@ -161,326 +77,58 @@ class _ShiftsPageState extends State<ShiftsPage> {
20, 20,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [ children: [
Row( const Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, "Shifts",
children: [ style: TextStyle(
const Text( fontSize: 24,
"Shifts", fontWeight: FontWeight.bold,
style: TextStyle( color: Colors.white,
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 SizedBox(height: 16),
// Tabs // Tabs
Row( Row(
children: [ children: [
_buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length), _buildTab(
"myshifts",
"My Shifts",
UiIcons.calendar,
myShifts.length,
),
const SizedBox(width: 8), 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), 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 // Body Content
Expanded( Expanded(
child: state is ShiftsLoading child: state is ShiftsLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : _buildTabContent(
padding: const EdgeInsets.symmetric(horizontal: 20), myShifts,
child: Column( pendingAssignments,
children: [ cancelledShifts,
const SizedBox(height: 20), availableJobs,
if (_activeTab == 'myshifts') ...[ historyShifts,
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),
],
),
),
), ),
], ],
), ),
@@ -490,62 +138,33 @@ class _ShiftsPageState extends State<ShiftsPage> {
); );
} }
String _formatDateStr(String dateStr) { Widget _buildTabContent(
try { List<Shift> myShifts,
final date = DateTime.parse(dateStr); List<Shift> pendingAssignments,
final now = DateTime.now(); List<Shift> cancelledShifts,
if (_isSameDay(date, now)) return "Today"; List<Shift> availableJobs,
final tomorrow = now.add(const Duration(days: 1)); List<Shift> historyShifts,
if (_isSameDay(date, tomorrow)) return "Tomorrow"; ) {
return DateFormat('EEE, MMM d').format(date); switch (_activeTab) {
} catch (_) { case 'myshifts':
return dateStr; 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) { Widget _buildTab(String id, String label, IconData icon, int count) {
final isActive = _activeTab == id; final isActive = _activeTab == id;
return Expanded( return Expanded(
@@ -554,112 +173,57 @@ class _ShiftsPageState extends State<ShiftsPage> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withAlpha((0.2 * 255).round()), color: isActive
borderRadius: BorderRadius.circular(8), ? Colors.white
: Colors.white.withAlpha((0.2 * 255).round()),
borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 14, color: isActive ? AppColors.krowBlue : Colors.white), Icon(
const SizedBox(width: 6), icon,
Flexible(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isActive ? AppColors.krowBlue : Colors.white), overflow: TextOverflow.ellipsis)), size: 14,
const SizedBox(width: 4), color: isActive ? AppColors.krowBlue : Colors.white,
Container( ),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), const SizedBox(width: 6),
constraints: const BoxConstraints(minWidth: 18), Flexible(
decoration: BoxDecoration( child: Text(
color: isActive ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) : Colors.white.withAlpha((0.2 * 255).round()), label,
borderRadius: BorderRadius.circular(999), 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))
]),
])),
]),
]),
),
);
}
} }

View File

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

View File

@@ -1,25 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:staff_shifts/src/presentation/navigation/shifts_navigator.dart';
class MyShiftCard extends StatefulWidget { class MyShiftCard extends StatefulWidget {
final Shift shift; final Shift shift;
final bool historyMode;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
final VoidCallback? onRequestSwap;
final int index;
const MyShiftCard({ const MyShiftCard({
super.key, super.key,
required this.shift, required this.shift,
this.historyMode = false,
this.onAccept,
this.onDecline,
this.onRequestSwap,
this.index = 0,
}); });
@override @override
@@ -27,8 +19,6 @@ class MyShiftCard extends StatefulWidget {
} }
class _MyShiftCardState extends State<MyShiftCard> { class _MyShiftCardState extends State<MyShiftCard> {
bool _isExpanded = false;
String _formatTime(String time) { String _formatTime(String time) {
if (time.isEmpty) return ''; if (time.isEmpty) return '';
try { try {
@@ -120,9 +110,10 @@ class _MyShiftCardState extends State<MyShiftCard> {
} }
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded), onTap: () {
child: AnimatedContainer( Modular.to.pushShiftDetails(widget.shift);
duration: const Duration(milliseconds: 300), },
child: Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
@@ -389,384 +380,9 @@ class _MyShiftCardState extends State<MyShiftCard> {
], ],
), ),
), ),
// 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,
),
),
],
),
);
}
} }

View File

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

View File

@@ -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<Shift> availableJobs;
const FindShiftsTab({
super.key,
required this.availableJobs,
});
@override
State<FindShiftsTab> createState() => _FindShiftsTabState();
}
class _FindShiftsTabState extends State<FindShiftsTab> {
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),
],
),
),
),
],
);
}
}

View File

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

View File

@@ -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<Shift> myShifts;
final List<Shift> pendingAssignments;
final List<Shift> cancelledShifts;
const MyShiftsTab({
super.key,
required this.myShifts,
required this.pendingAssignments,
required this.cancelledShifts,
});
@override
State<MyShiftsTab> createState() => _MyShiftsTabState();
}
class _MyShiftsTabState extends State<MyShiftsTab> {
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
List<DateTime> _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<ShiftsBloc>().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<ShiftsBloc>().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,
),
),
],
),
],
),
),
],
),
],
),
),
);
}
}

View File

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

View File

@@ -8,8 +8,10 @@ import 'domain/usecases/get_cancelled_shifts_usecase.dart';
import 'domain/usecases/get_history_shifts_usecase.dart'; import 'domain/usecases/get_history_shifts_usecase.dart';
import 'domain/usecases/accept_shift_usecase.dart'; import 'domain/usecases/accept_shift_usecase.dart';
import 'domain/usecases/decline_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 'domain/usecases/get_shift_details_usecase.dart';
import 'presentation/blocs/shifts/shifts_bloc.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/shifts_page.dart';
import 'presentation/pages/shift_details_page.dart'; import 'presentation/pages/shift_details_page.dart';
@@ -27,15 +29,16 @@ class StaffShiftsModule extends Module {
i.add(GetHistoryShiftsUseCase.new); i.add(GetHistoryShiftsUseCase.new);
i.add(AcceptShiftUseCase.new); i.add(AcceptShiftUseCase.new);
i.add(DeclineShiftUseCase.new); i.add(DeclineShiftUseCase.new);
i.add(ApplyForShiftUseCase.new);
i.add(GetShiftDetailsUseCase.new); i.add(GetShiftDetailsUseCase.new);
// Bloc // Bloc
i.add(ShiftsBloc.new); i.add(ShiftsBloc.new);
i.add(ShiftDetailsBloc.new);
} }
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
r.child('/', child: (_) => const ShiftsPage()); r.child('/', child: (_) => const ShiftsPage());
r.child('/details/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data));
} }
} }

View File

@@ -1,4 +1,6 @@
library staff_shifts; library staff_shifts;
export 'src/staff_shifts_module.dart'; export 'src/staff_shifts_module.dart';
export 'src/shift_details_module.dart';
export 'src/presentation/navigation/shifts_navigator.dart';

View File

@@ -77,6 +77,10 @@ class StaffMainModule extends Module {
'/availability', '/availability',
module: StaffAvailabilityModule(), module: StaffAvailabilityModule(),
); );
r.module(
'/shift-details',
module: ShiftDetailsModule(),
);
} }
} }