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:
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.krow_client"
|
||||
namespace = "com.krowwithus.client"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.krow_client
|
||||
package com.krowwithus.client
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
||||
set(BINARY_NAME "krow_client")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.krow_client")
|
||||
set(APPLICATION_ID "com.krowwithus.client")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.krow_staff"
|
||||
namespace = "com.krowwithus.staff"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
|
||||
@@ -193,6 +193,14 @@
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.krowwithus.staff",
|
||||
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.krow_staff
|
||||
package com.krowwithus.staff
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
||||
set(BINARY_NAME "krow_staff")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.krow_staff")
|
||||
set(APPLICATION_ID "com.krowwithus.staff")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -2,3 +2,4 @@ library core;
|
||||
|
||||
export 'src/domain/arguments/usecase_argument.dart';
|
||||
export 'src/domain/usecases/usecase.dart';
|
||||
export 'src/utils/date_time_utils.dart';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ class Shift extends Equatable {
|
||||
final double? longitude;
|
||||
final String? status;
|
||||
final int? durationDays; // For multi-day shifts
|
||||
final int? requiredSlots;
|
||||
final int? filledSlots;
|
||||
|
||||
const Shift({
|
||||
required this.id,
|
||||
@@ -49,6 +51,8 @@ class Shift extends Equatable {
|
||||
this.longitude,
|
||||
this.status,
|
||||
this.durationDays,
|
||||
this.requiredSlots,
|
||||
this.filledSlots,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -76,6 +80,8 @@ class Shift extends Equatable {
|
||||
longitude,
|
||||
status,
|
||||
durationDays,
|
||||
requiredSlots,
|
||||
filledSlots,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:developer' as developer;
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
InvalidCredentialsException,
|
||||
@@ -15,6 +14,7 @@ import 'package:krow_domain/krow_domain.dart'
|
||||
UnauthorizedAppException,
|
||||
PasswordMismatchException,
|
||||
GoogleOnlyAccountException;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import '../../domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart' as auth;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/usecases/submit_profile_setup_usecase.dart';
|
||||
|
||||
import 'profile_setup_event.dart';
|
||||
import 'profile_setup_state.dart';
|
||||
@@ -13,10 +10,8 @@ export 'profile_setup_state.dart';
|
||||
/// BLoC responsible for managing the profile setup state and logic.
|
||||
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
ProfileSetupBloc({
|
||||
required auth.FirebaseAuth firebaseAuth,
|
||||
required dc.ExampleConnector dataConnect,
|
||||
}) : _firebaseAuth = firebaseAuth,
|
||||
_dataConnect = dataConnect,
|
||||
required SubmitProfileSetup submitProfileSetup,
|
||||
}) : _submitProfileSetup = submitProfileSetup,
|
||||
super(const ProfileSetupState()) {
|
||||
on<ProfileSetupFullNameChanged>(_onFullNameChanged);
|
||||
on<ProfileSetupBioChanged>(_onBioChanged);
|
||||
@@ -27,8 +22,7 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
on<ProfileSetupSubmitted>(_onSubmitted);
|
||||
}
|
||||
|
||||
final auth.FirebaseAuth _firebaseAuth;
|
||||
final dc.ExampleConnector _dataConnect;
|
||||
final SubmitProfileSetup _submitProfileSetup;
|
||||
|
||||
/// Handles the [ProfileSetupFullNameChanged] event.
|
||||
void _onFullNameChanged(
|
||||
@@ -86,44 +80,14 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
emit(state.copyWith(status: ProfileSetupStatus.loading));
|
||||
|
||||
try {
|
||||
final auth.User? firebaseUser = _firebaseAuth.currentUser;
|
||||
if (firebaseUser == null) {
|
||||
throw Exception('User not authenticated.');
|
||||
}
|
||||
|
||||
final dc.StaffSession? session = dc.StaffSessionStore.instance.session;
|
||||
final String email = session?.user.email ?? '';
|
||||
final String? phone = firebaseUser.phoneNumber;
|
||||
|
||||
final fdc.OperationResult<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,
|
||||
await _submitProfileSetup(
|
||||
fullName: state.fullName,
|
||||
bio: state.bio.isEmpty ? null : state.bio,
|
||||
preferredLocations: state.preferredLocations,
|
||||
maxDistanceMiles: state.maxDistanceMiles,
|
||||
industries: state.industries,
|
||||
skills: state.skills,
|
||||
);
|
||||
if (session != null) {
|
||||
dc.StaffSessionStore.instance.setSession(
|
||||
dc.StaffSession(user: session.user, staff: staff),
|
||||
);
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ProfileSetupStatus.success));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
class GetStartedActions extends StatelessWidget {
|
||||
final VoidCallback onSignUpPressed;
|
||||
@@ -13,40 +14,15 @@ class GetStartedActions extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffAuthenticationGetStartedPageEn i18n =
|
||||
t.staff_authentication.get_started_page;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: onSignUpPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Create Account',
|
||||
style: UiTypography.buttonL.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: onLoginPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.primary,
|
||||
side: const BorderSide(color: UiColors.primary),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Log In',
|
||||
style: UiTypography.buttonL.copyWith(color: UiColors.primary),
|
||||
),
|
||||
),
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
UiButton.primary(onPressed: onSignUpPressed, text: i18n.sign_up_button),
|
||||
UiButton.secondary(onPressed: onLoginPressed, text: i18n.log_in_button),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the welcome text and description on the Get Started page.
|
||||
class GetStartedHeader extends StatelessWidget {
|
||||
/// Creates a [GetStartedHeader].
|
||||
const GetStartedHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffAuthenticationGetStartedPageEn i18n =
|
||||
t.staff_authentication.get_started_page;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Krow Workforce',
|
||||
style: UiTypography.display1b.copyWith(color: UiColors.textPrimary),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
RichText(
|
||||
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),
|
||||
Text(
|
||||
'Find flexible shifts that fit your schedule.',
|
||||
style: UiTypography.body1r.copyWith(color: UiColors.textSecondary),
|
||||
i18n.subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import 'package:staff_authentication/src/data/repositories_impl/auth_repository_
|
||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
|
||||
import 'package:staff_authentication/src/data/repositories_impl/profile_setup_repository_impl.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
|
||||
@@ -35,10 +38,17 @@ class StaffAuthenticationModule extends Module {
|
||||
dataConnect: ExampleConnector.instance,
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<ProfileSetupRepository>(
|
||||
() => ProfileSetupRepositoryImpl(
|
||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
||||
dataConnect: ExampleConnector.instance,
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(SignInWithPhoneUseCase.new);
|
||||
i.addLazySingleton(VerifyOtpUseCase.new);
|
||||
i.addLazySingleton(SubmitProfileSetup.new);
|
||||
|
||||
// BLoCs
|
||||
i.addLazySingleton<AuthBloc>(
|
||||
@@ -49,8 +59,7 @@ class StaffAuthenticationModule extends Module {
|
||||
);
|
||||
i.add<ProfileSetupBloc>(
|
||||
() => ProfileSetupBloc(
|
||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
||||
dataConnect: ExampleConnector.instance,
|
||||
submitProfileSetup: i.get<SubmitProfileSetup>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
extension TimestampExt on Timestamp {
|
||||
DateTime toDate() {
|
||||
return DateTime.fromMillisecondsSinceEpoch(seconds.toInt() * 1000 + nanoseconds ~/ 1000000);
|
||||
return DateTimeUtils.toDeviceTime(toDateTime());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class HomeRepositoryImpl implements HomeRepository {
|
||||
HomeRepositoryImpl();
|
||||
|
||||
@@ -63,7 +63,15 @@ class HomeRepositoryImpl implements HomeRepository {
|
||||
final response = await ExampleConnector.instance.listShifts().execute();
|
||||
|
||||
return response.data.shifts
|
||||
.where((s) => s.status is Known && (s.status as Known).value == ShiftStatus.OPEN)
|
||||
.where((s) {
|
||||
final isOpen = s.status is Known && (s.status as Known).value == ShiftStatus.OPEN;
|
||||
if (!isOpen) return false;
|
||||
|
||||
final start = s.startTime?.toDate();
|
||||
if (start == null) return false;
|
||||
|
||||
return start.isAfter(DateTime.now());
|
||||
})
|
||||
.take(10)
|
||||
.map((s) => _mapConnectorShiftToDomain(s))
|
||||
.toList();
|
||||
@@ -72,6 +80,12 @@ class HomeRepositoryImpl implements HomeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getStaffName() async {
|
||||
final session = StaffSessionStore.instance.session;
|
||||
return session?.staff?.name;
|
||||
}
|
||||
|
||||
// Mappers specific to Home's Domain Entity 'Shift'
|
||||
// Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for home screen data operations.
|
||||
///
|
||||
@@ -14,4 +14,7 @@ abstract class HomeRepository {
|
||||
|
||||
/// Retrieves shifts recommended for the worker based on their profile.
|
||||
Future<List<Shift>> getRecommendedShifts();
|
||||
|
||||
/// Retrieves the current staff member's name.
|
||||
Future<String?> getStaffName();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
/// Use case for fetching all shifts displayed on the home screen.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
@@ -10,9 +10,11 @@ part 'home_state.dart';
|
||||
/// Simple Cubit to manage home page state (shifts + loading/error).
|
||||
class HomeCubit extends Cubit<HomeState> {
|
||||
final GetHomeShifts _getHomeShifts;
|
||||
final HomeRepository _repository;
|
||||
|
||||
HomeCubit(HomeRepository repository)
|
||||
: _getHomeShifts = GetHomeShifts(repository),
|
||||
_repository = repository,
|
||||
super(const HomeState.initial());
|
||||
|
||||
Future<void> loadShifts() async {
|
||||
@@ -20,6 +22,7 @@ class HomeCubit extends Cubit<HomeState> {
|
||||
emit(state.copyWith(status: HomeStatus.loading));
|
||||
try {
|
||||
final result = await _getHomeShifts.call();
|
||||
final name = await _repository.getStaffName();
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -27,6 +30,7 @@ class HomeCubit extends Cubit<HomeState> {
|
||||
todayShifts: result.today,
|
||||
tomorrowShifts: result.tomorrow,
|
||||
recommendedShifts: result.recommended,
|
||||
staffName: name,
|
||||
// Mock profile status for now, ideally fetched from a user repository
|
||||
isProfileComplete: false,
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ class HomeState extends Equatable {
|
||||
final List<Shift> recommendedShifts;
|
||||
final bool autoMatchEnabled;
|
||||
final bool isProfileComplete;
|
||||
final String? staffName;
|
||||
final String? errorMessage;
|
||||
|
||||
const HomeState({
|
||||
@@ -18,6 +19,7 @@ class HomeState extends Equatable {
|
||||
this.recommendedShifts = const [],
|
||||
this.autoMatchEnabled = false,
|
||||
this.isProfileComplete = false,
|
||||
this.staffName,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@@ -30,6 +32,7 @@ class HomeState extends Equatable {
|
||||
List<Shift>? recommendedShifts,
|
||||
bool? autoMatchEnabled,
|
||||
bool? isProfileComplete,
|
||||
String? staffName,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return HomeState(
|
||||
@@ -39,18 +42,20 @@ class HomeState extends Equatable {
|
||||
recommendedShifts: recommendedShifts ?? this.recommendedShifts,
|
||||
autoMatchEnabled: autoMatchEnabled ?? this.autoMatchEnabled,
|
||||
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
||||
staffName: staffName ?? this.staffName,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
todayShifts,
|
||||
tomorrowShifts,
|
||||
recommendedShifts,
|
||||
autoMatchEnabled,
|
||||
isProfileComplete,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
status,
|
||||
todayShifts,
|
||||
tomorrowShifts,
|
||||
recommendedShifts,
|
||||
autoMatchEnabled,
|
||||
isProfileComplete,
|
||||
staffName,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Extension on [IModularNavigator] providing typed navigation helpers
|
||||
/// for the Staff Home feature (worker home screen).
|
||||
@@ -40,4 +41,9 @@ extension HomeNavigator on IModularNavigator {
|
||||
void pushSettings() {
|
||||
pushNamed('/settings');
|
||||
}
|
||||
|
||||
/// Navigates to the shift details page for the given [shift].
|
||||
void pushShiftDetails(Shift shift) {
|
||||
pushNamed('/worker-main/shift-details/${shift.id}', arguments: shift);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,12 @@ class WorkerHomePage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const HomeHeader(),
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
buildWhen: (previous, current) => previous.staffName != current.staffName,
|
||||
builder: (context, state) {
|
||||
return HomeHeader(userName: state.staffName);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: Column(
|
||||
|
||||
@@ -2,15 +2,21 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
/// Header widget for the staff home page, using design system tokens.
|
||||
class HomeHeader extends StatelessWidget {
|
||||
final String? userName;
|
||||
|
||||
/// Creates a [HomeHeader].
|
||||
const HomeHeader({super.key});
|
||||
const HomeHeader({super.key, this.userName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerI18n = t.staff.home.header;
|
||||
final nameToDisplay = userName ?? headerI18n.user_name_placeholder;
|
||||
final initial = nameToDisplay.isNotEmpty
|
||||
? nameToDisplay[0].toUpperCase()
|
||||
: 'K';
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
@@ -18,45 +24,42 @@ class HomeHeader extends StatelessWidget {
|
||||
UiConstants.space4,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child:Row(
|
||||
child: Row(
|
||||
spacing: UiConstants.space3,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: UiColors.primary.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: UiColors.primary.withOpacity(0.1),
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
color: UiColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: UiColors.primary.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: UiColors.primary.withOpacity(0.1),
|
||||
child: const Text(
|
||||
'K',
|
||||
style: TextStyle(
|
||||
color: UiColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerI18n.welcome_back,
|
||||
style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground),
|
||||
),
|
||||
Text(
|
||||
headerI18n.user_name_placeholder,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
headerI18n.welcome_back,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
Text(nameToDisplay, style: UiTypography.headline4m),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:staff_home/src/presentation/navigation/home_navigator.dart';
|
||||
|
||||
class RecommendedShiftCard extends StatelessWidget {
|
||||
final Shift shift;
|
||||
@@ -18,13 +19,7 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(recI18n.applied_for(title: shift.title)),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Modular.to.pushShiftDetails(shift);
|
||||
},
|
||||
child: Container(
|
||||
width: 300,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:staff_home/src/domain/entities/shift.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class ShiftCard extends StatefulWidget {
|
||||
final Shift shift;
|
||||
|
||||
@@ -28,6 +28,8 @@ dependencies:
|
||||
path: ../../../core
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
staff_shifts:
|
||||
path: ../shifts
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
firebase_data_connect:
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
@@ -51,27 +52,26 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
|
||||
DateTime? _toDateTime(dynamic t) {
|
||||
if (t == null) return null;
|
||||
if (t is DateTime) return t;
|
||||
if (t is String) return DateTime.tryParse(t);
|
||||
|
||||
// Data Connect Timestamp handling
|
||||
try {
|
||||
if (t is Timestamp) {
|
||||
return t.toDateTime();
|
||||
DateTime? dt;
|
||||
if (t is Timestamp) {
|
||||
dt = t.toDateTime();
|
||||
} else if (t is String) {
|
||||
dt = DateTime.tryParse(t);
|
||||
} else {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toJson() as String);
|
||||
} catch (_) {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toString());
|
||||
} catch (e) {
|
||||
dt = null;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// Fallback for any object that might have a toDate or similar
|
||||
if (t.runtimeType.toString().contains('Timestamp')) {
|
||||
return (t as dynamic).toDate();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
return DateTime.tryParse(t.toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (dt != null) {
|
||||
return DateTimeUtils.toDeviceTime(dt);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
status: _mapStatus(status),
|
||||
description: shift.description,
|
||||
durationDays: shift.durationDays,
|
||||
requiredSlots: shift.requiredSlots,
|
||||
filledSlots: shift.filledSlots,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -182,6 +184,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
status: s.status?.stringValue.toLowerCase() ?? 'open',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: null, // Basic list doesn't fetch detailed role stats yet
|
||||
filledSlots: null,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -210,6 +214,20 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
int? required;
|
||||
int? filled;
|
||||
try {
|
||||
final rolesRes = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
required = 0;
|
||||
filled = 0;
|
||||
for(var r in rolesRes.data.shiftRoles) {
|
||||
required = (required ?? 0) + r.count;
|
||||
filled = (filled ?? 0) + (r.assigned ?? 0);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final startDt = _toDateTime(s.startTime);
|
||||
final endDt = _toDateTime(s.endTime);
|
||||
final createdDt = _toDateTime(s.createdAt);
|
||||
@@ -229,6 +247,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
status: s.status?.stringValue ?? 'OPEN',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: required,
|
||||
filledSlots: filled,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -236,7 +256,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift(String shiftId) async {
|
||||
Future<void> applyForShift(String shiftId, {bool isInstantBook = false}) async {
|
||||
final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
|
||||
if (rolesResult.data.shiftRoles.isEmpty) throw Exception('No open roles for this shift');
|
||||
|
||||
@@ -246,8 +266,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
await _dataConnect.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: role.id,
|
||||
status: dc.ApplicationStatus.PENDING,
|
||||
roleId: role.roleId,
|
||||
status: isInstantBook ? dc.ApplicationStatus.ACCEPTED : dc.ApplicationStatus.PENDING,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
).execute();
|
||||
}
|
||||
@@ -286,6 +306,22 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
}
|
||||
|
||||
if (appId == null || roleId == null) {
|
||||
// If we are rejecting and can't find an application, create one as rejected (declining an available shift)
|
||||
if (newStatus == dc.ApplicationStatus.REJECTED) {
|
||||
final rolesResult = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
|
||||
if (rolesResult.data.shiftRoles.isNotEmpty) {
|
||||
final role = rolesResult.data.shiftRoles.first;
|
||||
final staffId = await _getStaffId();
|
||||
await _dataConnect.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: role.id,
|
||||
status: dc.ApplicationStatus.REJECTED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
).execute();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ abstract interface class ShiftsRepositoryInterface {
|
||||
Future<Shift?> getShiftDetails(String shiftId);
|
||||
|
||||
/// 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.
|
||||
Future<void> acceptShift(String shiftId);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -3,14 +3,12 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../../../domain/usecases/get_available_shifts_usecase.dart';
|
||||
import '../../../domain/arguments/get_available_shifts_arguments.dart';
|
||||
import '../../../domain/usecases/get_my_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
|
||||
import '../../../domain/usecases/get_available_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/get_cancelled_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/get_history_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/accept_shift_usecase.dart';
|
||||
import '../../../domain/usecases/decline_shift_usecase.dart';
|
||||
import '../../../domain/usecases/get_my_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
|
||||
|
||||
part 'shifts_event.dart';
|
||||
part 'shifts_state.dart';
|
||||
@@ -21,8 +19,6 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
final GetPendingAssignmentsUseCase getPendingAssignments;
|
||||
final GetCancelledShiftsUseCase getCancelledShifts;
|
||||
final GetHistoryShiftsUseCase getHistoryShifts;
|
||||
final AcceptShiftUseCase acceptShift;
|
||||
final DeclineShiftUseCase declineShift;
|
||||
|
||||
ShiftsBloc({
|
||||
required this.getMyShifts,
|
||||
@@ -30,13 +26,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
required this.getPendingAssignments,
|
||||
required this.getCancelledShifts,
|
||||
required this.getHistoryShifts,
|
||||
required this.acceptShift,
|
||||
required this.declineShift,
|
||||
}) : super(ShiftsInitial()) {
|
||||
on<LoadShiftsEvent>(_onLoadShifts);
|
||||
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
|
||||
on<AcceptShiftEvent>(_onAcceptShift);
|
||||
on<DeclineShiftEvent>(_onDeclineShift);
|
||||
}
|
||||
|
||||
Future<void> _onLoadShifts(
|
||||
@@ -63,7 +55,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
myShifts: myShiftsResult,
|
||||
pendingShifts: pendingResult,
|
||||
cancelledShifts: cancelledResult,
|
||||
availableShifts: availableResult,
|
||||
availableShifts: _filterPastShifts(availableResult),
|
||||
historyShifts: historyResult,
|
||||
searchQuery: '',
|
||||
jobType: 'all',
|
||||
@@ -89,7 +81,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
));
|
||||
|
||||
emit(currentState.copyWith(
|
||||
availableShifts: result,
|
||||
availableShifts: _filterPastShifts(result),
|
||||
searchQuery: event.query ?? currentState.searchQuery,
|
||||
jobType: event.jobType ?? currentState.jobType,
|
||||
));
|
||||
@@ -99,27 +91,16 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAcceptShift(
|
||||
AcceptShiftEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
try {
|
||||
await acceptShift(event.shiftId);
|
||||
add(LoadShiftsEvent()); // Reload lists
|
||||
} catch (_) {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeclineShift(
|
||||
DeclineShiftEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
try {
|
||||
await declineShift(event.shiftId);
|
||||
add(LoadShiftsEvent()); // Reload lists
|
||||
} catch (_) {
|
||||
// Handle error
|
||||
}
|
||||
List<Shift> _filterPastShifts(List<Shift> shifts) {
|
||||
final now = DateTime.now();
|
||||
return shifts.where((shift) {
|
||||
if (shift.date.isEmpty) return false;
|
||||
try {
|
||||
final shiftDate = DateTime.parse(shift.date);
|
||||
return shiftDate.isAfter(now);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
extension ShiftsNavigator on IModularNavigator {
|
||||
void pushShiftDetails(Shift shift) {
|
||||
pushNamed('/shifts/details/${shift.id}', arguments: shift);
|
||||
pushNamed('/worker-main/shift-details/${shift.id}', arguments: shift);
|
||||
}
|
||||
|
||||
// Example for going back or internal navigation if needed
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/shifts/shifts_bloc.dart';
|
||||
import '../widgets/my_shift_card.dart';
|
||||
import '../widgets/shift_assignment_card.dart';
|
||||
|
||||
// Shim to match POC styles locally
|
||||
class AppColors {
|
||||
static const Color krowBlue = UiColors.primary;
|
||||
static const Color krowYellow = Color(0xFFFFED4A);
|
||||
static const Color krowCharcoal = UiColors.textPrimary;
|
||||
static const Color krowMuted = UiColors.textSecondary;
|
||||
static const Color krowBorder = UiColors.border;
|
||||
static const Color krowBackground = UiColors.background;
|
||||
static const Color white = Colors.white;
|
||||
static const Color black = Colors.black;
|
||||
}
|
||||
import '../widgets/tabs/my_shifts_tab.dart';
|
||||
import '../widgets/tabs/find_shifts_tab.dart';
|
||||
import '../widgets/tabs/history_shifts_tab.dart';
|
||||
import '../styles/shifts_styles.dart';
|
||||
|
||||
class ShiftsPage extends StatefulWidget {
|
||||
final String? initialTab;
|
||||
@@ -31,15 +19,6 @@ class ShiftsPage extends StatefulWidget {
|
||||
|
||||
class _ShiftsPageState extends State<ShiftsPage> {
|
||||
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>();
|
||||
|
||||
@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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: BlocBuilder<ShiftsBloc, ShiftsState>(
|
||||
builder: (context, state) {
|
||||
final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : [];
|
||||
final List<Shift> availableJobs = (state is ShiftsLoaded) ? 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 : [];
|
||||
final List<Shift> myShifts = (state is ShiftsLoaded)
|
||||
? state.myShifts
|
||||
: [];
|
||||
final List<Shift> availableJobs = (state is ShiftsLoaded)
|
||||
? 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
|
||||
final filteredJobs = availableJobs.where((s) {
|
||||
final matchesSearch =
|
||||
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.location.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (_jobType == 'all') return true;
|
||||
if (_jobType == 'one-day') {
|
||||
return s.durationDays == null || s.durationDays! <= 1;
|
||||
}
|
||||
if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final calendarDays = _getCalendarDays();
|
||||
final weekStartDate = calendarDays.first;
|
||||
final weekEndDate = calendarDays.last;
|
||||
|
||||
final visibleMyShifts = myShifts.where((s) {
|
||||
try {
|
||||
final date = DateTime.parse(s.date);
|
||||
return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) &&
|
||||
date.isBefore(weekEndDate.add(const Duration(days: 1)));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
final visibleCancelledShifts = cancelledShifts.where((s) {
|
||||
try {
|
||||
final date = DateTime.parse(s.date);
|
||||
return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) &&
|
||||
date.isBefore(weekEndDate.add(const Duration(days: 1)));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
// Note: "filteredJobs" logic moved to FindShiftsTab
|
||||
// Note: Calendar logic moved to MyShiftsTab
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.krowBackground,
|
||||
@@ -161,326 +77,58 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
20,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Shifts",
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(UiIcons.user, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Text(
|
||||
"Shifts",
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tabs
|
||||
Row(
|
||||
children: [
|
||||
_buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length),
|
||||
_buildTab(
|
||||
"myshifts",
|
||||
"My Shifts",
|
||||
UiIcons.calendar,
|
||||
myShifts.length,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length),
|
||||
_buildTab(
|
||||
"find",
|
||||
"Find Shifts",
|
||||
UiIcons.search,
|
||||
availableJobs.length, // Passed unfiltered count as badge? Or logic inside? Pass availableJobs.
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTab("history", "History", UiIcons.clock, historyShifts.length),
|
||||
_buildTab(
|
||||
"history",
|
||||
"History",
|
||||
UiIcons.clock,
|
||||
historyShifts.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Calendar Selector
|
||||
if (_activeTab == 'myshifts')
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
|
||||
onPressed: () => setState(() => _weekOffset--),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
Text(
|
||||
DateFormat('MMMM yyyy').format(weekStartDate),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
|
||||
onPressed: () => setState(() => _weekOffset++),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Days Grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: calendarDays.map((date) {
|
||||
final isSelected = _isSameDay(date, _selectedDate);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
final hasShifts = myShifts.any((s) {
|
||||
try {
|
||||
return _isSameDay(DateTime.parse(s.date), date);
|
||||
} catch (_) { return false; }
|
||||
});
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = date),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.krowBlue : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.white : AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted,
|
||||
),
|
||||
),
|
||||
if (hasShifts && !isSelected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.krowBlue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_activeTab == 'myshifts')
|
||||
const Divider(height: 1, color: AppColors.krowBorder),
|
||||
|
||||
// Search and Filters for Find Tab (Fixed at top)
|
||||
if (_activeTab == 'find')
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(UiIcons.search, size: 20, color: Color(0xFF94A3B8)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: "Search jobs, location...",
|
||||
hintStyle: TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
width: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: const Icon(UiIcons.filter, size: 18, color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Filter Tabs
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterTab('all', 'All Jobs'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterTab('one-day', 'One Day'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterTab('multi-day', 'Multi-Day'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterTab('long-term', 'Long Term'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Body Content
|
||||
Expanded(
|
||||
child: state is ShiftsLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
if (_activeTab == 'myshifts') ...[
|
||||
if (pendingAssignments.isNotEmpty) ...[
|
||||
_buildSectionHeader("Awaiting Confirmation", const Color(0xFFF59E0B)),
|
||||
...pendingAssignments.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ShiftAssignmentCard(
|
||||
shift: shift,
|
||||
onConfirm: () => _confirmShift(shift.id),
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
isConfirming: true,
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
if (visibleCancelledShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader("Cancelled Shifts", AppColors.krowMuted),
|
||||
...visibleCancelledShifts.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _buildCancelledCard(
|
||||
title: shift.title,
|
||||
client: shift.clientName,
|
||||
pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}",
|
||||
rate: "\$${shift.hourlyRate}/hr · 8h",
|
||||
date: _formatDateStr(shift.date),
|
||||
time: "${shift.startTime} - ${shift.endTime}",
|
||||
address: shift.locationAddress,
|
||||
isLastMinute: true,
|
||||
onTap: () {}
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Confirmed Shifts
|
||||
if (visibleMyShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader("Confirmed Shifts", AppColors.krowMuted),
|
||||
...visibleMyShifts.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MyShiftCard(shift: shift),
|
||||
)),
|
||||
],
|
||||
|
||||
if (visibleMyShifts.isEmpty && pendingAssignments.isEmpty && cancelledShifts.isEmpty)
|
||||
_buildEmptyState(UiIcons.calendar, "No shifts this week", "Try finding new jobs in the Find tab", null, null),
|
||||
],
|
||||
|
||||
if (_activeTab == 'find') ...[
|
||||
if (filteredJobs.isEmpty)
|
||||
_buildEmptyState(UiIcons.search, "No jobs available", "Check back later", null, null)
|
||||
else
|
||||
...filteredJobs.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MyShiftCard(
|
||||
shift: shift,
|
||||
onAccept: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Shift Booked!'),
|
||||
backgroundColor: Color(0xFF10B981),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDecline: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Shift Declined'),
|
||||
backgroundColor: Color(0xFFEF4444),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
||||
if (_activeTab == 'history') ...[
|
||||
if (historyShifts.isEmpty)
|
||||
_buildEmptyState(UiIcons.clock, "No shift history", "Completed shifts appear here", null, null)
|
||||
else
|
||||
...historyShifts.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MyShiftCard(
|
||||
shift: shift,
|
||||
historyMode: true,
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: state is ShiftsLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildTabContent(
|
||||
myShifts,
|
||||
pendingAssignments,
|
||||
cancelledShifts,
|
||||
availableJobs,
|
||||
historyShifts,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -490,62 +138,33 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateStr(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
if (_isSameDay(date, now)) return "Today";
|
||||
final tomorrow = now.add(const Duration(days: 1));
|
||||
if (_isSameDay(date, tomorrow)) return "Tomorrow";
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
Widget _buildTabContent(
|
||||
List<Shift> myShifts,
|
||||
List<Shift> pendingAssignments,
|
||||
List<Shift> cancelledShifts,
|
||||
List<Shift> availableJobs,
|
||||
List<Shift> historyShifts,
|
||||
) {
|
||||
switch (_activeTab) {
|
||||
case 'myshifts':
|
||||
return MyShiftsTab(
|
||||
myShifts: myShifts,
|
||||
pendingAssignments: pendingAssignments,
|
||||
cancelledShifts: cancelledShifts,
|
||||
);
|
||||
case 'find':
|
||||
return FindShiftsTab(
|
||||
availableJobs: availableJobs,
|
||||
);
|
||||
case 'history':
|
||||
return HistoryShiftsTab(
|
||||
historyShifts: historyShifts,
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, Color dotColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dotColor == AppColors.krowMuted ? AppColors.krowMuted : dotColor
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterTab(String id, String label) {
|
||||
final isSelected = _jobType == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _jobType = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.krowBlue : Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.krowBlue : const Color(0xFFE2E8F0),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? Colors.white : const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab(String id, String label, IconData icon, int count) {
|
||||
final isActive = _activeTab == id;
|
||||
return Expanded(
|
||||
@@ -554,112 +173,57 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: isActive ? AppColors.krowBlue : Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isActive ? AppColors.krowBlue : Colors.white), overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) : Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isActive ? AppColors.krowBlue : Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isActive ? AppColors.krowBlue : Colors.white,
|
||||
),
|
||||
child: Center(child: Text("$count", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isActive ? AppColors.krowBlue : Colors.white))),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppColors.krowBlue.withAlpha((0.1 * 255).round())
|
||||
: Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"$count",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isActive ? AppColors.krowBlue : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(IconData icon, String title, String subtitle, String? actionLabel, VoidCallback? onAction) {
|
||||
return Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 64), child: Column(children: [
|
||||
Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.krowCharcoal)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: const TextStyle(fontSize: 14, color: AppColors.krowMuted)),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: onAction, style: ElevatedButton.styleFrom(backgroundColor: AppColors.krowBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), child: Text(actionLabel)),
|
||||
]
|
||||
])));
|
||||
}
|
||||
|
||||
Widget _buildCancelledCard({required String title, required String client, required String pay, required String rate, required String date, required String time, required String address, required bool isLastMinute, required VoidCallback onTap}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.krowBorder)
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))),
|
||||
if (isLastMinute) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))
|
||||
]
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.krowBlue.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)),
|
||||
Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))
|
||||
])),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)),
|
||||
Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))
|
||||
])
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)),
|
||||
const SizedBox(width: 12),
|
||||
const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))
|
||||
]),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:staff_shifts/src/presentation/navigation/shifts_navigator.dart';
|
||||
|
||||
class MyShiftCard extends StatefulWidget {
|
||||
final Shift shift;
|
||||
final bool historyMode;
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
final VoidCallback? onRequestSwap;
|
||||
final int index;
|
||||
|
||||
const MyShiftCard({
|
||||
super.key,
|
||||
required this.shift,
|
||||
this.historyMode = false,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.onRequestSwap,
|
||||
this.index = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -27,8 +19,6 @@ class MyShiftCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MyShiftCardState extends State<MyShiftCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
String _formatTime(String time) {
|
||||
if (time.isEmpty) return '';
|
||||
try {
|
||||
@@ -120,9 +110,10 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
onTap: () {
|
||||
Modular.to.pushShiftDetails(widget.shift);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -389,384 +380,9 @@ class _MyShiftCardState extends State<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import 'domain/usecases/get_cancelled_shifts_usecase.dart';
|
||||
import 'domain/usecases/get_history_shifts_usecase.dart';
|
||||
import 'domain/usecases/accept_shift_usecase.dart';
|
||||
import 'domain/usecases/decline_shift_usecase.dart';
|
||||
import 'domain/usecases/apply_for_shift_usecase.dart';
|
||||
import 'domain/usecases/get_shift_details_usecase.dart';
|
||||
import 'presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'presentation/blocs/shift_details/shift_details_bloc.dart';
|
||||
import 'presentation/pages/shifts_page.dart';
|
||||
import 'presentation/pages/shift_details_page.dart';
|
||||
|
||||
@@ -27,15 +29,16 @@ class StaffShiftsModule extends Module {
|
||||
i.add(GetHistoryShiftsUseCase.new);
|
||||
i.add(AcceptShiftUseCase.new);
|
||||
i.add(DeclineShiftUseCase.new);
|
||||
i.add(ApplyForShiftUseCase.new);
|
||||
i.add(GetShiftDetailsUseCase.new);
|
||||
|
||||
// Bloc
|
||||
i.add(ShiftsBloc.new);
|
||||
i.add(ShiftDetailsBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child('/', child: (_) => const ShiftsPage());
|
||||
r.child('/details/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
library staff_shifts;
|
||||
|
||||
export 'src/staff_shifts_module.dart';
|
||||
export 'src/shift_details_module.dart';
|
||||
export 'src/presentation/navigation/shifts_navigator.dart';
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ class StaffMainModule extends Module {
|
||||
'/availability',
|
||||
module: StaffAvailabilityModule(),
|
||||
);
|
||||
r.module(
|
||||
'/shift-details',
|
||||
module: ShiftDetailsModule(),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user