feat: legacy mobile apps created

This commit is contained in:
Achintha Isuru
2025-12-02 23:51:04 -05:00
parent 850441ca64
commit 8e7753b324
1519 changed files with 0 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/core/data/models/skill.dart';
part 'business_skill.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class BusinessSkill {
Skill? skill;
BusinessSkill({this.skill});
factory BusinessSkill.fromJson(Map<String, dynamic> json) {
return _$BusinessSkillFromJson(json);
}
Map<String, dynamic> toJson() => _$BusinessSkillToJson(this);
}

View File

@@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
part 'cancellation_reason.g.dart';
@JsonEnum(fieldRename: FieldRename.snake)
enum CancellationReason {
sickLeave,
vacation,
other,
health,
transportation,
personal,
scheduleConflict,
}
@JsonSerializable(fieldRename: FieldRename.snake)
class CancellationReasonModel {
final String type;
final CancellationReason? reason;
final String? details;
CancellationReasonModel({
required this.type,
required this.reason,
required this.details,
});
factory CancellationReasonModel.fromJson(Map<String, dynamic> json) {
return _$CancellationReasonModelFromJson(json);
}
Map<String, dynamic> toJson() => _$CancellationReasonModelToJson(this);
}

View File

@@ -0,0 +1,64 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/features/shifts/data/models/event_tag.dart';
part 'event.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Event {
String? id;
Business? business;
String? name;
String? date;
String? additionalInfo;
List<Addon>? addons;
List<EventTag>? tags;
Event({
this.business,
this.name,
this.date,
this.additionalInfo,
this.addons,
this.tags,
});
factory Event.fromJson(Map<String, dynamic> json) {
return _$EventFromJson(json);
}
Map<String, dynamic> toJson() => _$EventToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class Business {
String? name;
String? avatar;
Business({this.name, this.avatar});
factory Business.fromJson(Map<String, dynamic> json) {
return _$BusinessFromJson(json);
}
Map<String, dynamic> toJson() => _$BusinessToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class Addon {
String? name;
Addon({this.name});
factory Addon.fromJson(Map<String, dynamic> json) {
return Addon(
name: json['name'],
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
};
}
}

View File

@@ -0,0 +1,31 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/core/data/models/staff/full_address_model.dart';
import 'package:krow/features/shifts/data/models/event.dart';
import 'package:krow/features/shifts/data/models/shift_contact.dart';
part 'event_shift.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Shift {
String? id;
String? name;
String? address;
FullAddress? fullAddress;
List<Contact>? contacts;
Event? event;
Shift({
this.id,
this.name,
this.address,
this.event,
this.contacts,
this.fullAddress,
});
factory Shift.fromJson(Map<String, dynamic> json) {
return _$ShiftFromJson(json);
}
Map<String, dynamic> toJson() => _$ShiftToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'event_tag.g.dart';
@JsonSerializable()
class EventTag{
final String id;
final String name;
final String? slug;
EventTag({required this.name, required this.id, required this.slug});
factory EventTag.fromJson(Map<String, dynamic> json) {
return _$EventTagFromJson(json);
}
Map<String, dynamic> toJson() => _$EventTagToJson(this);
}

View File

@@ -0,0 +1,30 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/features/shifts/data/models/business_skill.dart';
import 'package:krow/features/shifts/data/models/event_shift.dart';
part 'position.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Position {
String? id;
String? startTime;
String? endTime;
Shift? shift;
BusinessSkill? businessSkill;
double? rate;
@JsonKey(name: 'break')
int? breakMinutes;
Position({
this.id,
this.startTime,
this.endTime,
this.shift,
this.businessSkill,
});
factory Position.fromJson(Map<String, dynamic> json) =>
_$PositionFromJson(json);
Map<String, dynamic> toJson() => _$PositionToJson(this);
}

View File

@@ -0,0 +1,45 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'shift_contact.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Contact {
final String id;
final String firstName;
final String? avatar;
final String lastName;
final String title;
final AuthInfo authInfo;
Contact({
required this.id,
required this.firstName,
required this.lastName,
required this.title,
required this.avatar,
required this.authInfo,
});
factory Contact.fromJson(Map<String, dynamic> json) {
return _$ContactFromJson(json);
}
Map<String, dynamic> toJson() => _$ContactToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class AuthInfo {
final String email;
final String phone;
AuthInfo({
required this.email,
required this.phone,
});
factory AuthInfo.fromJson(Map<String, dynamic> json) {
return _$AuthInfoFromJson(json);
}
Map<String, dynamic> toJson() => _$AuthInfoToJson(this);
}

View File

@@ -0,0 +1,73 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/features/shifts/data/models/cancellation_reason.dart';
import 'package:krow/features/shifts/data/models/position.dart';
part 'staff_shift.g.dart';
@JsonEnum(fieldRename: FieldRename.snake)
enum EventShiftRoleStaffStatus {
assigned,
confirmed,
ongoing,
completed,
canceledByStaff,
canceledByBusiness,
canceledByAdmin,
requestedReplace,
declineByStaff,
}
@JsonSerializable(fieldRename: FieldRename.snake)
class StaffShift {
String id;
DateTime? statusUpdatedAt;
EventShiftRoleStaffStatus status;
Position? position;
DateTime? startAt;
DateTime? endAt;
DateTime? clockIn;
DateTime? clockOut;
DateTime? breakIn;
DateTime? breakOut;
List<CancellationReasonModel>? cancelReason;
StaffRating? rating;
// Staff? staff;
StaffShift({
required this.id,
required this.status,
this.statusUpdatedAt,
this.position,
this.startAt,
this.endAt,
this.clockIn,
this.clockOut,
this.breakIn,
this.breakOut,
this.rating,
// this.staff
});
factory StaffShift.fromJson(Map<String, dynamic> json) {
return _$StaffShiftFromJson(json);
}
Map<String, dynamic> toJson() => _$StaffShiftToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class StaffRating{
final String id;
final double rating;
factory StaffRating.fromJson(Map<String, dynamic> json) {
return _$StaffRatingFromJson(json);
}
StaffRating({required this.id, required this.rating});
Map<String, dynamic> toJson() => _$StaffRatingToJson(this);
}

View File

@@ -0,0 +1,124 @@
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/application/clients/api/api_exception.dart';
import 'package:krow/core/data/models/pagination_wrapper/pagination_wrapper.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/data/shifts_gql.dart';
@Injectable()
class ShiftsApiProvider {
final ApiClient _client;
ShiftsApiProvider({required ApiClient client}) : _client = client;
Future<PaginationWrapper> fetchShifts(String status, {String? after}) async {
final QueryResult result = await _client.query(
schema: getShiftsQuery,
body: {'status': status, 'first': 100, 'after': after},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return PaginationWrapper.fromJson(
result.data!['staff_shifts'],
(json) => StaffShift.fromJson(json),
);
}
Future<PaginationWrapper> getMissBreakFinishedShift() async {
final QueryResult result = await _client.query(
schema: staffNoBreakShifts,
body: {'first': 100},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return PaginationWrapper.fromJson(
result.data!['staff_no_break_shifts'],
(json) => StaffShift.fromJson(json),
);
}
Future<void> confirmShift(String id) async {
final QueryResult result =
await _client.mutate(schema: acceptShiftMutation, body: {'id': id});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> clockInShift(String id) async {
final QueryResult result =
await _client.mutate(schema: trackStaffClockin, body: {'id': id});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> completeShift(String id,
{String? breakIn, String? breakOut, bool isPast = false}) async {
final QueryResult result = await _client.mutate(
schema: isPast ? trackStaffBreak : completeShiftMutation,
body: {'id': id, 'break_in': breakIn, 'break_out': breakOut});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> completeShiftNoBreak(String id,
{String? reason, String? additionalReason}) async {
final QueryResult result = await _client.mutate(
schema: submitNoBreakStaffShiftMutation,
body: {'id': id, 'reason': reason, 'details': additionalReason});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> declineShift(String id,
{String? reason, String? additionalReason}) async {
final QueryResult result = await _client.mutate(
schema: declineStaffShiftMutation,
body: {
'position_id': id,
'reason': reason,
'details': additionalReason
});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> cancelShift(String id,
{String? reason, String? additionalReason}) async {
final QueryResult result = await _client.mutate(
schema: cancelStaffShiftMutation,
body: {
'position_id': id,
'reason': reason,
'details': additionalReason
});
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<StaffShift> getShiftById(String id) async {
final QueryResult result = await _client.query(
schema: getShiftPositionQuery,
body: {'id': id},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
if (result.data == null || result.data!['staff_shift'] == null) {
throw Exception('No data found');
}
return StaffShift.fromJson(result.data!['staff_shift']);
}
}

View File

@@ -0,0 +1,207 @@
import 'package:krow/core/application/clients/api/gql.dart';
const String _shiftFields = '''
id
status
status_updated_at
start_at
end_at
clock_in
clock_out
break_in
break_out
...position
cancel_reason {
type
reason
details
}
rating {
id
rating
}
''';
const String getShiftsQuery = '''
$_positionFragment
query GetShifts (\$status: EventShiftPositionStaffStatusInput!, \$first: Int!, \$after: String) {
staff_shifts(status: \$status, first: \$first, after: \$after) {
pageInfo {
hasNextPage
}
edges {
node {
$_shiftFields
}
cursor
}
}
}
''';
const String staffNoBreakShifts = '''
$_positionFragment
query staffNoBreakShifts (\$first: Int!) {
staff_no_break_shifts(first: \$first) {
pageInfo{
}
edges {
node {
$_shiftFields
}
}
}
}
''';
const String getShiftPositionQuery = '''
$_positionFragment
query GetShiftPosition (\$id: ID!) {
staff_shift(id: \$id) {
$_shiftFields
}
}
''';
const String acceptShiftMutation = '''
$_positionFragment
mutation AcceptShift(\$id: ID!) {
accept_shift(position_id: \$id) {
$_shiftFields
}
}
''';
const String trackStaffClockin = '''
mutation TrackStaffClockin(\$id: ID!) {
track_staff_clockin(position_staff_id: \$id) {
}
}
''';
const String completeShiftMutation = '''
mutation CompleteShift(\$id: ID!, \$break_in: DateTime, \$break_out: DateTime) {
track_staff_clockout(position_staff_id: \$id, break_in: \$break_in, break_out: \$break_out) {
}
}
''';
const String trackStaffBreak = '''
mutation trackStaffBreak(\$id: ID!, \$break_in: DateTime!, \$break_out: DateTime!) {
track_staff_break(position_staff_id: \$id, break_in: \$break_in, break_out: \$break_out) {
}
}
''';
const String submitNoBreakStaffShiftMutation = '''
mutation SubmitNoBreakStaffShift(\$id: ID!, \$reason: NoBreakShiftPenaltyLogReason, \$details: String) {
submit_no_break_staff_shift(position_staff_id: \$id, reason: \$reason, details: \$details) {
}
}
''';
const String cancelStaffShiftMutation = '''
mutation cancelStaffShiftMutation(\$position_id: ID!, \$reason: CancelShiftPenaltyLogReason, \$details: String) {
cancel_staff_shift(position_staff_id: \$position_id, reason: \$reason, details: \$details) {
}
}
''';
const String declineStaffShiftMutation = '''
mutation DeclineStaffShift(\$position_id: ID!,\$reason: DeclineShiftPenaltyLogReason, \$details: String) {
decline_shift(position_id: \$position_id, reason: \$reason, details: \$details) {
}
}
''';
const _positionFragment = '''
$skillFragment
fragment position on EventShiftPositionStaff {
position {
id
start_time
end_time
rate
break
...shift
...business_skill
}
}
fragment shift on EventShiftPosition {
shift {
id
name
address
...FullAddress
...contacts
event {
id
date
name
...business
additional_info
tags{
id
name
slug
}
addons{
name
}
}
}
}
fragment business_skill on EventShiftPosition {
business_skill {
skill {
...SkillFragment
}
}
}
fragment business on Event {
business {
name
avatar
}
}
fragment contacts on EventShift {
contacts {
id
first_name
last_name
avatar
title
auth_info {
email
phone
}
}
}
fragment FullAddress on EventShift {
full_address {
street_number
zip_code
latitude
longitude
formatted_address
street
region
city
country
}
}
''';

View File

@@ -0,0 +1,155 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:intl/intl.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/data/shifts_api_provider.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/shift_complete_dialog.dart';
@Singleton(as: ShiftsRepository)
class ShiftsRepositoryImpl extends ShiftsRepository {
final ShiftsApiProvider _apiProvider;
StreamController<EventShiftRoleStaffStatus>? _statusController;
ShiftsRepositoryImpl({required ShiftsApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Stream<EventShiftRoleStaffStatus> get statusStream {
_statusController ??=
StreamController<EventShiftRoleStaffStatus>.broadcast();
return _statusController!.stream;
}
@override
Future<List<ShiftEntity>> getShifts(
{String? lastItemId, required ShiftStatusFilterType statusFilter}) async {
var paginationWrapper = await _apiProvider
.fetchShifts(statusFilterToGqlString(statusFilter), after: lastItemId);
return paginationWrapper.edges.map((e) {
return ShiftEntity.fromStaffShift(
e.node,
cursor: (paginationWrapper.pageInfo?.hasNextPage??false) ? e.cursor : null,
);
}).toList();
}
statusFilterToGqlString(ShiftStatusFilterType statusFilter) {
return statusFilter.name;
}
@override
Future<void> confirmShift(ShiftEntity shiftViewModel) async {
var result = await _apiProvider.confirmShift(shiftViewModel.id);
_statusController?.add(EventShiftRoleStaffStatus.assigned);
_statusController?.add(EventShiftRoleStaffStatus.confirmed);
return result;
}
@override
Future<void> clockInShift(ShiftEntity shiftViewModel) async {
// try {
await _apiProvider.clockInShift(shiftViewModel.id);
_statusController?.add(EventShiftRoleStaffStatus.assigned);
_statusController?.add(EventShiftRoleStaffStatus.ongoing);
// } catch (e) {
// _statusController?.add(EventShiftRoleStaffStatus.assigned);
// _statusController?.add(EventShiftRoleStaffStatus.ongoing);
// rethrow;
// }
}
@override
Future<void> completeShift(
ShiftEntity shiftViewModel, ClockOutDetails clockOutDetails, bool isPast) async {
if (clockOutDetails.reason != null) {
_apiProvider.completeShiftNoBreak(
shiftViewModel.id,
reason: clockOutDetails.reason,
additionalReason: clockOutDetails.additionalReason,
);
} else {
var breakInTime =
DateFormat('H:mm').parse(clockOutDetails.breakStartTime!);
var start = DateTime(
shiftViewModel.startDate.year,
shiftViewModel.startDate.month,
shiftViewModel.startDate.day,
breakInTime.hour,
breakInTime.minute);
var breakOutTime =
DateFormat('H:mm').parse(clockOutDetails.breakEndTime!);
var end = DateTime(
shiftViewModel.startDate.year,
shiftViewModel.startDate.month,
shiftViewModel.startDate.day,
breakOutTime.hour,
breakOutTime.minute);
_apiProvider.completeShift(
shiftViewModel.id,
isPast: isPast,
breakIn: DateFormat('yyyy-MM-dd HH:mm:ss').format(start),
breakOut: DateFormat('yyyy-MM-dd HH:mm:ss').format(end),
);
}
_statusController?.add(EventShiftRoleStaffStatus.ongoing);
_statusController?.add(EventShiftRoleStaffStatus.completed);
}
@override
Future<void> forceClockOut(String id) async {
await _apiProvider.completeShift(id);
_statusController?.add(EventShiftRoleStaffStatus.assigned);
_statusController?.add(EventShiftRoleStaffStatus.confirmed);
}
@override
void dispose() {
_statusController?.close();
_statusController = null;
}
@override
declineShift(
ShiftEntity shiftViewModel, String? reason, String? additionalReason) {
_apiProvider.declineShift(
shiftViewModel.id,
reason: reason,
additionalReason: additionalReason,
);
_statusController?.add(EventShiftRoleStaffStatus.assigned);
_statusController?.add(EventShiftRoleStaffStatus.canceledByStaff);
}
@override
cancelShift(
ShiftEntity shiftViewModel, String? reason, String? additionalReason) {
_apiProvider.cancelShift(
shiftViewModel.id,
reason: reason,
additionalReason: additionalReason,
);
_statusController?.add(EventShiftRoleStaffStatus.assigned);
_statusController?.add(EventShiftRoleStaffStatus.canceledByStaff);
}
@override
Future<ShiftEntity?> getShiftById(String id) async {
return ShiftEntity.fromStaffShift(
cursor: '', await _apiProvider.getShiftById(id));
}
@override
Future<List<ShiftEntity>> getMissBreakFinishedShift() async{
var paginationWrapper = await _apiProvider
.getMissBreakFinishedShift();
return paginationWrapper.edges.map((e) {
return ShiftEntity.fromStaffShift(
e.node,
cursor: (paginationWrapper.pageInfo?.hasNextPage??false) ? e.cursor : null,
);
}).toList();
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/common/date_time_extension.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_event.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_state.dart';
class CompleteDialogBloc
extends Bloc<CompleteDialogEvent, CompleteDialogState> {
CompleteDialogBloc() : super(const CompleteDialogState()) {
on<InitializeCompleteDialog>((event, emit) {
emit(state.copyWith(
minLimit: event.minLimit,
startTime: event.minLimit?.toHourMinuteString() ?? DateTime.now().toHourMinuteString(),
endTime: event.minLimit?.add(Duration(minutes: event.breakDurationInMinutes)).toHourMinuteString() ?? DateTime.now().add(Duration(minutes: event.breakDurationInMinutes)).toHourMinuteString(),
breakDuration: Duration(minutes: event.breakDurationInMinutes),
));
});
on<SelectBreakStatus>((event, emit) {
emit(state.copyWith(status: event.status));
});
on<SelectReason>((event, emit) {
emit(state.copyWith(selectedReason: event.reason));
});
on<ChangeStartTime>((event, emit) {
emit(state.copyWith(startTime: event.startTime.toHourMinuteString(),endTime: event.startTime.add(state.breakDuration).toHourMinuteString()));
});
on<ChangeEndTime>((event, emit) {
emit(state.copyWith(endTime: event.endTime.toHourMinuteString()));
});
on<ChangeAdditionalReason>((event, emit) {
emit(state.copyWith(additionalReason: event.additionalReason));
});
}
}

View File

@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_state.dart';
abstract class CompleteDialogEvent extends Equatable {
@override
List<Object?> get props => [];
}
class InitializeCompleteDialog extends CompleteDialogEvent {
final DateTime? minLimit;
final int breakDurationInMinutes;
InitializeCompleteDialog(
{this.minLimit, required this.breakDurationInMinutes});
@override
List<Object?> get props => [minLimit];
}
class SelectBreakStatus extends CompleteDialogEvent {
final BreakStatus status;
SelectBreakStatus(this.status);
@override
List<Object?> get props => [status];
}
class SelectReason extends CompleteDialogEvent {
final String reason;
SelectReason(this.reason);
@override
List<Object?> get props => [reason];
}
class ChangeStartTime extends CompleteDialogEvent {
final DateTime startTime;
ChangeStartTime(this.startTime);
@override
List<Object?> get props => [startTime];
}
class ChangeEndTime extends CompleteDialogEvent {
final DateTime endTime;
ChangeEndTime(this.endTime);
@override
List<Object?> get props => [endTime];
}
class ChangeAdditionalReason extends CompleteDialogEvent {
final String additionalReason;
ChangeAdditionalReason(this.additionalReason);
@override
List<Object?> get props => [additionalReason];
}

View File

@@ -0,0 +1,73 @@
import 'package:equatable/equatable.dart';
import 'package:intl/intl.dart';
enum BreakStatus { neutral, positive, negative }
class CompleteDialogState extends Equatable {
final BreakStatus status;
final String? selectedReason;
final String startTime;
final String endTime;
final String additionalReason;
final DateTime? minLimit;
final Duration breakDuration;
const CompleteDialogState({
this.status = BreakStatus.neutral,
this.selectedReason,
this.startTime = '00:00',
this.endTime = '00:00',
this.additionalReason = '',
this.minLimit,
this.breakDuration = const Duration(minutes: 0),
});
String? get breakTimeInputError {
if (startTime == endTime) {
return 'Break start time and end time cannot be the same';
}
if (DateFormat('H:mm')
.parse(startTime)
.isAfter(DateFormat('H:mm').parse(endTime))) {
return 'Break start time cannot be after break end time';
}
if (DateFormat('H:mm').parse(endTime).isAfter(DateTime.now())) {
return 'Break end time cannot be in the future';
}
if (minLimit != null) {
final start = DateFormat('H:mm').parse(startTime);
final min =
DateFormat('H:mm').parse(DateFormat('H:mm').format(minLimit!));
if (start.isBefore(min)) {
return 'Break start time cannot be before the shift start time';
}
}
return null;
}
CompleteDialogState copyWith({
BreakStatus? status,
String? selectedReason,
String? startTime,
String? endTime,
String? additionalReason,
DateTime? minLimit,
Duration? breakDuration,
}) {
return CompleteDialogState(
status: status ?? this.status,
selectedReason: selectedReason ?? this.selectedReason,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
additionalReason: additionalReason ?? this.additionalReason,
minLimit: minLimit ?? this.minLimit,
breakDuration: breakDuration ?? this.breakDuration,
);
}
@override
List<Object?> get props =>
[status, selectedReason, startTime, endTime, additionalReason];
}

View File

@@ -0,0 +1,309 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/clients/api/api_exception.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/sevices/geofencing_serivce.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/services/force_clockout_service.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
part 'shift_details_event.dart';
part 'shift_details_state.dart';
class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState> {
ShiftDetailsBloc()
: super(ShiftDetailsState(shiftViewModel: ShiftEntity.empty)) {
on<ShiftDetailsInitialEvent>(_onInitial);
on<StaffGeofencingUpdate>(_onStaffGeofencingUpdate);
on<ShiftUpdateTimerEvent>(_onUpdateTimer);
on<ShiftConfirmEvent>(_onConfirm);
on<ShiftClockInEvent>(_onClockIn);
on<ShiftCompleteEvent>(_onComplete);
on<ShiftDeclineEvent>(_onDecline);
on<ShiftCancelEvent>(_onCancel);
on<ShiftRefreshEvent>(_onRefresh);
on<ShiftCheckGeocodingEvent>(_onCheckGeocoding);
on<ShiftErrorWasShownEvent>(_onErrorWasShown);
}
final GeofencingService _geofencingService = getIt<GeofencingService>();
Timer? _timer;
Timer? _refreshTimer;
StreamSubscription<bool>? _geofencingStream;
Future<void> _onInitial(
ShiftDetailsInitialEvent event,
Emitter<ShiftDetailsState> emit,
) async {
emit(state.copyWith(shiftViewModel: event.shift));
if (event.shift.status == EventShiftRoleStaffStatus.ongoing) {
_startOngoingTimer();
}
_runRefreshTimer();
add(const ShiftCheckGeocodingEvent());
}
void _onStaffGeofencingUpdate(
StaffGeofencingUpdate event,
Emitter<ShiftDetailsState> emit,
) {
emit(state.copyWith(isToFar: !event.isInRange));
}
void _onUpdateTimer(
ShiftUpdateTimerEvent event,
Emitter<ShiftDetailsState> emit,
) {
emit(state.copyWith(shiftViewModel: state.shiftViewModel.copyWith()));
}
void _onConfirm(
ShiftConfirmEvent event,
Emitter<ShiftDetailsState> emit,
) async {
try {
emit(state.copyWith(isLoading: true));
await getIt<ShiftsRepository>().confirmShift(state.shiftViewModel);
emit(
state.copyWith(
shiftViewModel: state.shiftViewModel.copyWith(
status: EventShiftRoleStaffStatus.confirmed,
),
),
);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(errorMessage: e.message));
}else{
emit(state.copyWith(errorMessage: e.toString()));
}
}
emit(state.copyWith(isLoading: false));
}
void _onClockIn(
ShiftClockInEvent event, Emitter<ShiftDetailsState> emit) async {
emit(state.copyWith(isLoading: true));
try {
await getIt<ShiftsRepository>().clockInShift(state.shiftViewModel);
_geofencingStream?.cancel();
emit(
state.copyWith(
isLoading: false,
shiftViewModel: state.shiftViewModel.copyWith(
status: EventShiftRoleStaffStatus.ongoing,
clockIn: DateTime.now(),
),
),
);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(errorMessage: e.message));
}else{
emit(state.copyWith(errorMessage: e.toString()));
}
emit(state.copyWith(isLoading: false));
}
_startOngoingTimer();
getIt<ForceClockoutService>().startTrackOngoingLocation(
state.shiftViewModel,
() {
onForceUpdateUI();
},
);
}
void _onComplete(
ShiftCompleteEvent event,
Emitter<ShiftDetailsState> emit,
) async {
emit(state.copyWith(isLoading: true));
try {
emit(
state.copyWith(
shiftViewModel: state.shiftViewModel.copyWith(
status: EventShiftRoleStaffStatus.completed,
),
),
);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(errorMessage: e.message));
}else{
emit(state.copyWith(errorMessage: e.toString()));
}
}
emit(state.copyWith(isLoading: false));
}
void _onDecline(
ShiftDeclineEvent event,
Emitter<ShiftDetailsState> emit,
) async {
emit(state.copyWith(isLoading: true));
try {
await getIt<ShiftsRepository>().declineShift(
state.shiftViewModel, event.reason, event.additionalReason);
emit(state.copyWith(
needPop: true,
shiftViewModel: state.shiftViewModel.copyWith(
status: EventShiftRoleStaffStatus.canceledByStaff,
)));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(errorMessage: e.message));
}else{
emit(state.copyWith(errorMessage: e.toString()));
}
}
emit(state.copyWith(isLoading: false));
}
void _onErrorWasShown(
ShiftErrorWasShownEvent event,
Emitter<ShiftDetailsState> emit,
) {
emit(state.copyWith(errorMessage: ''));
}
void _onCancel(
ShiftCancelEvent event,
Emitter<ShiftDetailsState> emit,
) async {
emit(state.copyWith(isLoading: true));
try {
await getIt<ShiftsRepository>().cancelShift(
state.shiftViewModel, event.reason, event.additionalReason);
emit(state.copyWith(
shiftViewModel: state.shiftViewModel.copyWith(
status: EventShiftRoleStaffStatus.canceledByStaff,
)));
} catch (e) {
print('!!!!!! ${e}');
if (e is DisplayableException) {
emit(state.copyWith(errorMessage: e.message));
}else{
emit(state.copyWith(errorMessage: e.toString()));
}
}
emit(state.copyWith(isLoading: false));
}
void _onRefresh(
ShiftRefreshEvent event,
Emitter<ShiftDetailsState> emit,
) {
emit(state.copyWith(
shiftViewModel: event.shift,
proximityState: GeofencingProximityState.none));
}
void _onCheckGeocoding(
ShiftCheckGeocodingEvent event,
Emitter<ShiftDetailsState> emit,
) async {
emit(state.copyWith(
proximityState: GeofencingProximityState.none,
));
await _checkByGeocoding(emit);
}
@override
Future<void> close() {
_timer?.cancel();
_refreshTimer?.cancel();
_geofencingStream?.cancel();
return super.close();
}
void _runRefreshTimer() {
_refreshTimer = Timer.periodic(
const Duration(seconds: 10),
(timer) async {
final shift = await getIt<ShiftsRepository>()
.getShiftById(state.shiftViewModel.id);
if (shift == null) {
return;
}
add((ShiftRefreshEvent(shift)));
},
);
}
Future<void> _checkByGeocoding(Emitter<ShiftDetailsState> emit) async {
if (![
EventShiftRoleStaffStatus.assigned,
EventShiftRoleStaffStatus.confirmed,
EventShiftRoleStaffStatus.ongoing
].contains(state.shiftViewModel.status)) {
return;
}
final geolocationCheck =
await _geofencingService.requestGeolocationPermission();
emit(
state.copyWith(
proximityState: switch (geolocationCheck) {
GeolocationStatus.disabled =>
GeofencingProximityState.locationDisabled,
GeolocationStatus.denied => GeofencingProximityState.permissionDenied,
GeolocationStatus.prohibited => GeofencingProximityState.goToSettings,
GeolocationStatus.onlyInUse => GeofencingProximityState.onlyInUse,
GeolocationStatus.enabled => null,
},
),
);
if (geolocationCheck == GeolocationStatus.enabled) {
_geofencingStream = _geofencingService
.isInRangeStream(
pointLatitude: state.shiftViewModel.locationLat,
pointLongitude: state.shiftViewModel.locationLon,
)
.listen(
(isInRange) {
add(StaffGeofencingUpdate(isInRange: isInRange));
},
);
}
}
void _startOngoingTimer() {
_timer?.cancel();
_timer = Timer.periodic(
const Duration(seconds: 10),
(timer) {
if (state.shiftViewModel.status != EventShiftRoleStaffStatus.ongoing) {
timer.cancel();
} else {
add(const ShiftUpdateTimerEvent());
}
},
);
}
Future<void> onForceUpdateUI() async {
_timer?.cancel();
if (!isClosed) {
final shift =
await getIt<ShiftsRepository>().getShiftById(state.shiftViewModel.id);
if (shift == null) {
return;
}
add((ShiftRefreshEvent(shift)));
}
}
}

View File

@@ -0,0 +1,62 @@
part of 'shift_details_bloc.dart';
@immutable
sealed class ShiftDetailsEvent {
const ShiftDetailsEvent();
}
class ShiftDetailsInitialEvent extends ShiftDetailsEvent {
final ShiftEntity shift;
const ShiftDetailsInitialEvent({required this.shift});
}
class StaffGeofencingUpdate extends ShiftDetailsEvent {
const StaffGeofencingUpdate({required this.isInRange});
final bool isInRange;
}
class ShiftUpdateTimerEvent extends ShiftDetailsEvent {
const ShiftUpdateTimerEvent();
}
class ShiftCompleteEvent extends ShiftDetailsEvent {
const ShiftCompleteEvent();
}
class ShiftClockInEvent extends ShiftDetailsEvent {
const ShiftClockInEvent();
}
class ShiftConfirmEvent extends ShiftDetailsEvent {
const ShiftConfirmEvent();
}
class ShiftDeclineEvent extends ShiftDetailsEvent {
final String? reason;
final String? additionalReason;
const ShiftDeclineEvent(this.reason, this.additionalReason);
}
class ShiftCancelEvent extends ShiftDetailsEvent {
final String? reason;
final String? additionalReason;
const ShiftCancelEvent(this.reason, this.additionalReason);
}
class ShiftRefreshEvent extends ShiftDetailsEvent {
final ShiftEntity shift;
const ShiftRefreshEvent(this.shift);
}
class ShiftCheckGeocodingEvent extends ShiftDetailsEvent {
const ShiftCheckGeocodingEvent();
}
class ShiftErrorWasShownEvent extends ShiftDetailsEvent {
const ShiftErrorWasShownEvent();
}

View File

@@ -0,0 +1,47 @@
part of 'shift_details_bloc.dart';
@immutable
class ShiftDetailsState {
final ShiftEntity shiftViewModel;
final bool isToFar;
final bool isLoading;
final GeofencingProximityState proximityState;
final String? errorMessage;
final bool needPop;
const ShiftDetailsState({
required this.shiftViewModel,
this.isToFar = true,
this.isLoading = false,
this.proximityState = GeofencingProximityState.none,
this.errorMessage,
this.needPop = false,
});
ShiftDetailsState copyWith({
ShiftEntity? shiftViewModel,
bool? isToFar,
bool? isLoading,
GeofencingProximityState? proximityState,
String? errorMessage,
bool? needPop
}) {
return ShiftDetailsState(
shiftViewModel: shiftViewModel ?? this.shiftViewModel,
isToFar: isToFar ?? this.isToFar,
isLoading: isLoading ?? false,
proximityState: proximityState ?? this.proximityState,
errorMessage: errorMessage,
needPop: needPop ?? this.needPop,
);
}
}
enum GeofencingProximityState {
none,
tooFar,
locationDisabled,
goToSettings,
onlyInUse,
permissionDenied,
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_event.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_state.dart';
import 'package:krow/features/shifts/domain/services/force_clockout_service.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
var indexToStatus = <int, ShiftStatusFilterType>{
0: ShiftStatusFilterType.assigned,
1: ShiftStatusFilterType.confirmed,
2: ShiftStatusFilterType.ongoing,
3: ShiftStatusFilterType.completed,
4: ShiftStatusFilterType.canceled,
};
ShiftsBloc()
: super(const ShiftsState(tabs: {
0: ShiftTabState(items: [], isLoading: true),
1: ShiftTabState(items: []),
2: ShiftTabState(items: []),
3: ShiftTabState(items: []),
4: ShiftTabState(items: []),
})) {
on<ShiftsInitialEvent>(_onInitial);
on<ShiftsTabChangedEvent>(_onTabChanged);
on<LoadTabShiftEvent>(_onLoadTabItems);
on<LoadMoreShiftEvent>(_onLoadMoreTabItems);
on<ReloadMissingBreakShift>(_onReloadMissingBreakShift);
getIt<ShiftsRepository>().statusStream.listen((event) {
add(LoadTabShiftEvent(status: event.index));
});
}
Future<void> _onInitial(ShiftsInitialEvent event, emit) async {
add(const LoadTabShiftEvent(status: 0));
add(const LoadTabShiftEvent(status: 2));
var missedShifts =
await getIt<ShiftsRepository>().getMissBreakFinishedShift();
if (missedShifts.isNotEmpty) {
emit(state.copyWith(
missedShifts: missedShifts,
));
}
}
Future<void> _onTabChanged(ShiftsTabChangedEvent event, emit) async {
emit(state.copyWith(tabIndex: event.tabIndex));
final currentTabState = state.tabs[event.tabIndex]!;
if (currentTabState.items.isEmpty && !currentTabState.isLoading) {
add(LoadTabShiftEvent(status: event.tabIndex));
}
}
Future<void> _onLoadTabItems(LoadTabShiftEvent event, emit) async {
await _fetchShifts(event.status, null, emit);
}
Future<void> _onLoadMoreTabItems(LoadMoreShiftEvent event, emit) async {
final currentTabState = state.tabs[event.status]!;
if (!currentTabState.hasMoreItems || currentTabState.isLoading) return;
await _fetchShifts(event.status, currentTabState.items, emit);
}
_fetchShifts(int tabIndex, List<ShiftEntity>? previousItems, emit) async {
if (previousItems != null && previousItems.lastOrNull?.cursor == null) {
return;
}
final currentTabState = state.tabs[tabIndex]!;
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: currentTabState.copyWith(isLoading: true),
},
));
try {
var items = await getIt<ShiftsRepository>().getShifts(
statusFilter: indexToStatus[tabIndex]!,
lastItemId: previousItems?.lastOrNull?.cursor,
);
// if(items.isNotEmpty){
// items = List.generate(20, (i)=>items[0]);
// }
var allItems = (previousItems ?? [])..addAll(items);
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: currentTabState.copyWith(
items: allItems,
hasMoreItems: items.isNotEmpty,
isLoading: false,
),
},
));
if (tabIndex == 2 &&
allItems.isNotEmpty ) {
getIt<ForceClockoutService>()
.startTrackOngoingLocation(allItems.first, () {});
}
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: currentTabState.copyWith(isLoading: false),
},
));
}
}
Future<void> _onReloadMissingBreakShift(
ReloadMissingBreakShift event, emit) async {
emit(state.copyWith(missedShifts: []));
var missedShifts =
await getIt<ShiftsRepository>().getMissBreakFinishedShift();
emit(state.copyWith(missedShifts: missedShifts));
}
@override
Future<void> close() {
getIt<ShiftsRepository>().dispose();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
sealed class ShiftsEvent {
const ShiftsEvent();
}
class ShiftsInitialEvent extends ShiftsEvent {
const ShiftsInitialEvent();
}
class ShiftsTabChangedEvent extends ShiftsEvent {
final int tabIndex;
const ShiftsTabChangedEvent({required this.tabIndex});
}
class LoadTabShiftEvent extends ShiftsEvent {
final int status;
const LoadTabShiftEvent({required this.status});
}
class LoadMoreShiftEvent extends ShiftsEvent {
final int status;
const LoadMoreShiftEvent({required this.status});
}
class ReloadMissingBreakShift extends ShiftsEvent {
const ReloadMissingBreakShift();
}

View File

@@ -0,0 +1,53 @@
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftsState {
final bool isLoading;
final int tabIndex;
final Map<int, ShiftTabState> tabs;
final List<ShiftEntity> missedShifts;
const ShiftsState(
{this.isLoading = false,
this.tabIndex = 0,
required this.tabs,
this.missedShifts = const []});
ShiftsState copyWith({
bool? isLoading,
int? tabIndex,
Map<int, ShiftTabState>? tabs,
List<ShiftEntity>? missedShifts,
}) {
return ShiftsState(
isLoading: isLoading ?? this.isLoading,
tabIndex: tabIndex ?? this.tabIndex,
tabs: tabs ?? this.tabs,
missedShifts: missedShifts ?? this.missedShifts,
);
}
}
class ShiftTabState {
final List<ShiftEntity> items;
final bool isLoading;
final bool hasMoreItems;
const ShiftTabState({
required this.items,
this.isLoading = false,
this.hasMoreItems = true,
});
ShiftTabState copyWith({
List<ShiftEntity>? items,
bool? isLoading,
bool? hasMoreItems,
}) {
return ShiftTabState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
hasMoreItems: hasMoreItems ?? this.hasMoreItems,
);
}
}

View File

@@ -0,0 +1,44 @@
import 'dart:async';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/sevices/background_service/background_task.dart';
import 'package:krow/core/sevices/geofencing_serivce.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
class ContinuousClockoutCheckerTask implements BackgroundTask {
@override
Future<void> oneTime(ServiceInstance? service) async {
// var shift = (await getIt<ShiftsRepository>()
// .getShifts(statusFilter: ShiftStatusFilterType.ongoing)
// .onError((error, stackTrace) {
// return [];
// }))
// .firstOrNull;
//
// if (shift == null) {
// if (service is AndroidServiceInstance) {
// service.setAsBackgroundService();
// }
// return;
// } else {
// GeofencingService geofencingService = getIt<GeofencingService>();
// try {
// var permission = await geofencingService.requestGeolocationPermission();
// if (permission == GeolocationStatus.enabled) {
// var inArea = await geofencingService.isInRangeCheck(
// pointLatitude: shift.locationLat,
// pointLongitude: shift.locationLon,
// range: 500,
// );
// if (!inArea) {
// await getIt<ShiftsRepository>().forceClockOut(shift.id);
// }
// }
// } catch (e) {}
// }
}
@override
Future<void> stop() async {}
}

View File

@@ -0,0 +1,37 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/sevices/geofencing_serivce.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
StreamSubscription? geofencingClockOutStream;
@singleton
class ForceClockoutService {
final GeofencingService geofencingService;
ForceClockoutService(this.geofencingService);
startTrackOngoingLocation(ShiftEntity shift, VoidCallback? onClockOut) {
// if (geofencingClockOutStream != null) {
// geofencingClockOutStream?.cancel();
// }
//
// geofencingClockOutStream = geofencingService
// .isInRangeStream(
// pointLatitude: shift.locationLat, pointLongitude: shift.locationLon)
// .listen(
// (isInRange) async {
// if (!isInRange) {
// await getIt<ShiftsRepository>().forceClockOut(shift.id);
// onClockOut?.call();
// geofencingClockOutStream?.cancel();
// }
// },
// );
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/domain/shifts_repository.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/shift_complete_dialog.dart';
class ShiftCompleterService {
Future<void> startCompleteProcess(BuildContext context, ShiftEntity shift,
{required Null Function() onComplete, bool canSkip = true}) async {
var result = await ShiftCompleteDialog.showCustomDialog(
context,
canSkip,
shift.eventName,
shift.clockIn ?? DateTime.now(),
shift.planingBreakTime ?? 30);
if (result != null) {
if(!kDebugMode)
await getIt<ShiftsRepository>()
.completeShift(shift, result['details'], !canSkip);
if (result['result'] == true) {
await KwDialog.show(
context: context,
icon: Assets.images.icons.medalStar,
state: KwDialogState.positive,
title: 'Congratulations, Shift Completed!',
message:
'Your break has been logged and added to your timeline. Keep up the good work!',
primaryButtonLabel: 'Back to Shift');
} else {
await KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'Your Selection is under review',
message:
'Labor Code § 512 requires California employers to give unpaid lunch breaks to non-exempt employees. Lunch breaks must be uninterrupted. Employers cannot require employees to do any work while on their lunch breaks. They also cannot discourage employees from taking one. However, the employer and employee can agree to waive the meal break if the workers shift is less than 6 hours.',
child: const Text(
'Once resolved you will be notify.\nNo further Action',
textAlign: TextAlign.center,
style: AppTextStyles.bodyMediumMed,
),
primaryButtonLabel: 'Continue');
}
}
}
}

View File

@@ -0,0 +1,213 @@
import 'package:flutter/foundation.dart';
import 'package:krow/features/shifts/data/models/cancellation_reason.dart';
import 'package:krow/features/shifts/data/models/event.dart';
import 'package:krow/features/shifts/data/models/event_tag.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
@immutable
class ShiftEntity {
final String id;
final String imageUrl;
final String skillName;
final String businessName;
final DateTime assignedDate;
final DateTime startDate;
final DateTime endDate;
final DateTime? clockIn;
final DateTime? clockOut;
final String locationName;
final double locationLat;
final double locationLon;
final EventShiftRoleStaffStatus status;
final String? rate;
final List<EventTag>? tags;
final StaffRating? rating;
final int? planingBreakTime;
final int? totalBreakTime;
final int? paymentStatus;
final int? canceledReason;
final List<Addon>? additionalData;
final List<ShiftManager> managers;
final String? additionalInfo;
final String? cursor;
final CancellationReason? cancellationReason;
final String eventId;
final String eventName;
const ShiftEntity({
required this.id,
required this.skillName,
required this.businessName,
required this.locationName,
required this.locationLat,
required this.locationLon,
required this.status,
required this.rate,
required this.imageUrl,
required this.assignedDate,
required this.startDate,
required this.endDate,
required this.clockIn,
required this.clockOut,
required this.tags,
required this.planingBreakTime,
required this.totalBreakTime,
required this.paymentStatus,
required this.canceledReason,
required this.additionalData,
required this.managers,
required this.rating,
required this.additionalInfo,
required this.eventId,
required this.eventName,
this.cancellationReason,
this.cursor,
});
ShiftEntity copyWith({
String? id,
String? imageUrl,
String? skillName,
String? businessName,
DateTime? assignedDate,
DateTime? startDate,
DateTime? endDate,
DateTime? clockIn,
DateTime? clockOut,
String? locationName,
double? locationLat,
double? locationLon,
EventShiftRoleStaffStatus? status,
String? rate,
List<EventTag>? tags,
StaffRating? rating,
int? planingBreakTime,
int? totalBreakTime,
int? paymentStatus,
int? canceledReason,
List<Addon>? additionalData,
List<ShiftManager>? managers,
String? additionalInfo,
String? eventId,
String? eventName,
String? cursor,
}) =>
ShiftEntity(
id: id ?? this.id,
imageUrl: imageUrl ?? this.imageUrl,
skillName: skillName ?? this.skillName,
businessName: businessName ?? this.businessName,
locationName: locationName ?? this.locationName,
locationLat: locationLat ?? this.locationLat,
locationLon: locationLon ?? this.locationLon,
status: status ?? this.status,
rate: rate ?? this.rate,
assignedDate: assignedDate ?? this.assignedDate,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
clockIn: clockIn ?? this.clockIn,
clockOut: clockOut ?? this.clockOut,
tags: tags ?? this.tags,
planingBreakTime: planingBreakTime ?? this.planingBreakTime,
totalBreakTime: totalBreakTime ?? this.totalBreakTime,
paymentStatus: paymentStatus ?? this.paymentStatus,
canceledReason: canceledReason ?? this.canceledReason,
additionalData: additionalData ?? this.additionalData,
managers: managers ?? this.managers,
rating: rating ?? this.rating,
additionalInfo: additionalInfo ?? this.additionalInfo,
cancellationReason: cancellationReason,
eventId: eventId ?? this.eventId,
eventName: eventName ?? this.eventName,
cursor: cursor ?? this.cursor,
);
static ShiftEntity empty = ShiftEntity(
id: '',
skillName: '',
businessName: '',
locationName: '',
locationLat: .0,
locationLon: .0,
status: EventShiftRoleStaffStatus.assigned,
rate: '',
imageUrl: '',
assignedDate: DateTime.now(),
clockIn: null,
clockOut: null,
startDate: DateTime.now(),
endDate: DateTime.now(),
tags: [],
planingBreakTime: 0,
totalBreakTime: 0,
paymentStatus: 0,
canceledReason: 0,
rating: StaffRating(id: '0', rating: 0),
additionalData: [],
managers: [],
eventId: '',
eventName: '',
additionalInfo: null,
);
static ShiftEntity fromStaffShift(
StaffShift shift, {
required String? cursor,
}) {
return ShiftEntity(
id: shift.id,
eventId: shift.position?.shift?.event?.id ?? '',
eventName: shift.position?.shift?.event?.name ?? '',
cursor: cursor,
skillName: shift.position?.businessSkill?.skill?.name ?? '',
businessName: shift.position?.shift?.event?.business?.name ?? '',
locationName: shift.position?.shift?.fullAddress?.formattedAddress ?? '',
locationLat: shift.position?.shift?.fullAddress?.latitude ?? 0,
locationLon: shift.position?.shift?.fullAddress?.longitude ?? 0,
status: shift.status,
rate: shift.position?.rate?.toStringAsFixed(2) ?? '0',
imageUrl: shift.position?.shift?.event?.business?.avatar ?? '',
additionalInfo: shift.position?.shift?.event?.additionalInfo,
assignedDate: shift.statusUpdatedAt ?? DateTime.now(),
clockIn: shift.clockIn,
clockOut: shift.clockOut,
startDate: shift.startAt ?? DateTime.now(),
endDate: shift.endAt ?? DateTime.now(),
tags: shift.position?.shift?.event?.tags,
planingBreakTime: shift.position?.breakMinutes ?? 0,
totalBreakTime: shift.breakOut
?.difference(shift.breakIn ?? DateTime.now())
.inMinutes ??
0,
paymentStatus: 0,
canceledReason: 0,
rating: shift.rating,
cancellationReason: shift.cancelReason?.firstOrNull?.reason,
additionalData: shift.position?.shift?.event?.addons ?? [],
managers: [
for (final contact in shift.position?.shift?.contacts ?? [])
ShiftManager(
id: contact.id,
name: '${contact.firstName} ${contact.lastName}',
imageUrl: contact.avatar ?? '',
phoneNumber: contact.authInfo.phone,
)
],
);
}
}
@immutable
class ShiftManager {
final String id;
final String name;
final String imageUrl;
final String phoneNumber;
const ShiftManager({
required this.id,
required this.name,
required this.imageUrl,
required this.phoneNumber,
});
}

View File

@@ -0,0 +1,33 @@
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/shift_complete_dialog.dart';
enum ShiftStatusFilterType { assigned, confirmed, ongoing, completed, canceled }
abstract class ShiftsRepository {
Stream<EventShiftRoleStaffStatus> get statusStream;
Future<List<ShiftEntity>> getShifts(
{String? lastItemId, required ShiftStatusFilterType statusFilter});
Future<void> confirmShift(ShiftEntity shiftViewModel);
Future<void> clockInShift(ShiftEntity shiftViewModel);
Future<void> completeShift(
ShiftEntity shiftViewModel, ClockOutDetails clockOutDetails,bool isPast);
Future<void> forceClockOut(String id);
void dispose();
declineShift(
ShiftEntity shiftViewModel, String? reason, String? additionalReason);
cancelShift(
ShiftEntity shiftViewModel, String? reason, String? additionalReason);
Future<ShiftEntity?> getShiftById(String id);
Future<List<ShiftEntity>> getMissBreakFinishedShift();
}

View File

@@ -0,0 +1,104 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/shifts/presentation/dialogs/cancel_dialog/cancel_reason_dropdown.dart';
class CancelDialogView extends StatefulWidget {
const CancelDialogView({super.key});
@override
State<CancelDialogView> createState() => _CancelDialogViewState();
}
class _CancelDialogViewState extends State<CancelDialogView> {
String selectedReason = '';
final _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'cancel_shift'.tr(),
style: AppTextStyles.headingH1.copyWith(height: 1),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
'please_select_reason'.tr(),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
const Gap(8),
CancelReasonDropdown(
selectedReason: selectedReason,
onReasonSelected: (String reason) {
setState(() {
selectedReason = reason;
});
},
),
const Gap(8),
KwTextInput(
controller: _textEditingController,
minHeight: 144,
maxLength: 300,
showCounter: true,
radius: 12,
title: 'additional_reasons'.tr(),
hintText: 'enter_main_text'.tr(),
),
const Gap(24),
_buttonGroup(context),
],
),
),
),
),
);
}
Widget _buttonGroup(
BuildContext context,
) {
return Column(
children: [
KwButton.primary(
disabled: selectedReason.isEmpty,
label: 'submit_reason'.tr(),
onPressed: () {
context.maybePop({
'reason': selectedReason,
'additionalReason': _textEditingController.text,
});
},
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'cancel'.tr(),
onPressed: () {
context.maybePop();
}),
],
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_menu.dart';
const reasons = {
'sick_leave': 'sick_leave',
'vacation ': 'vacation',
'other': 'other_specify',
};
class CancelReasonDropdown extends StatelessWidget {
final String? selectedReason;
final Function(String reason) onReasonSelected;
const CancelReasonDropdown(
{super.key,
required this.selectedReason,
required this.onReasonSelected});
@override
Widget build(BuildContext context) {
return Column(
children: buildReasonInput(context, selectedReason),
);
}
List<Widget> buildReasonInput(BuildContext context, String? selectedReason) {
return [
const Gap(24),
Row(
children: [
const Gap(16),
Text(
'reason'.tr(),
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
],
),
const Gap(4),
KwPopupMenu(
horizontalPadding: 40,
fit: KwPopupMenuFit.expand,
customButtonBuilder: (context, isOpened) {
return _buildMenuButton(isOpened, selectedReason);
},
menuItems: [
...reasons.entries
.map((e) => _buildMenuItem(context, e, selectedReason ?? ''))
])
];
}
Container _buildMenuButton(bool isOpened, String? selectedReason) {
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isOpened ? AppColors.bgColorDark : AppColors.grayTintStroke,
width: 1),
),
child: Row(
children: [
Expanded(
child: Text(
reasons[selectedReason]?.tr() ?? 'select_reason_from_list'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: selectedReason == null
? AppColors.blackGray
: AppColors.blackBlack),
)),
],
),
);
}
KwPopupMenuItem _buildMenuItem(BuildContext context,
MapEntry<String, String> entry, String selectedReason) {
return KwPopupMenuItem(
title: entry.value.tr(),
onTap: () {
onReasonSelected(entry.key);
},
icon: Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: selectedReason != entry.key ? null : AppColors.bgColorDark,
shape: BoxShape.circle,
border: selectedReason == entry.key
? null
: Border.all(color: AppColors.grayTintStroke, width: 1),
),
child: selectedReason == entry.key
? Center(
child: Assets.images.icons.check.svg(
height: 10,
width: 10,
colorFilter: const ColorFilter.mode(
AppColors.grayWhite, BlendMode.srcIn),
))
: null,
),
textStyle: AppTextStyles.bodySmallMed,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:krow/features/shifts/presentation/dialogs/cancel_dialog/cancel_dialog_view.dart';
class ShiftCancelDialog extends StatelessWidget {
const ShiftCancelDialog({super.key});
static Future<Map<String, dynamic>?> showCustomDialog(
BuildContext context) async {
return await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => const CancelDialogView(),
);
}
@override
Widget build(BuildContext context) {
return const CancelDialogView();
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_event.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/widgets/complete_dialog_view.dart';
//TODO(Artem: create widgets instead helper methods. Add initial break time values.Move reasons to state. Create Time slots validator.)
class ShiftCompleteDialog {
static Future<Map<String, dynamic>?> showCustomDialog(BuildContext context,
bool canSkip, String eventName, DateTime minLimit, int breakDurationInMinutes) async {
return showDialog<Map<String, dynamic>>(
barrierDismissible: canSkip,
context: context,
builder: (context) => BlocProvider(
create: (_) => CompleteDialogBloc()
..add(
InitializeCompleteDialog(
minLimit: minLimit,
breakDurationInMinutes:breakDurationInMinutes , // Default break duration
),
),
child: CompleteDialogView(
canSkip: canSkip,
eventName: eventName,
),
),
);
}
}
class ClockOutDetails {
final String? breakStartTime;
final String? breakEndTime;
final String? reason;
final String? additionalReason;
ClockOutDetails._(
{this.breakStartTime,
this.breakEndTime,
this.reason,
this.additionalReason});
factory ClockOutDetails.positive(String breakStartTime, String breakEndTime) {
return ClockOutDetails._(
breakStartTime: breakStartTime,
breakEndTime: breakEndTime,
);
}
factory ClockOutDetails.negative(String reason, String additionalReason) {
return ClockOutDetails._(
reason: reason,
additionalReason: additionalReason,
);
}
factory ClockOutDetails.empty() {
return ClockOutDetails._();
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/application/common/date_time_extension.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/kw_time_slot.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_event.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_state.dart';
class BreakTimePicker extends StatelessWidget {
final CompleteDialogState state;
const BreakTimePicker({super.key, required this.state});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: KwTimeSlotInput(
label: 'Break Start Time',
initialValue: DateFormat('H:mm').parse(state.startTime),
onChange: (v) => context.read<CompleteDialogBloc>().add(
ChangeStartTime(v),
),
),
),
const Gap(12),
Expanded(
child: KwTimeSlotInput(
editable: false,
label: 'Break End Time',
initialValue: DateFormat('H:mm').parse(state.endTime),
onChange: (v) {},
),
),
],
),
if (state.breakTimeInputError != null)
Padding(
padding: const EdgeInsets.only(top: 8, left: 16),
child: Text(
state.breakTimeInputError!,
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.statusError),
),
),
],
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_event.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_state.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/shift_complete_dialog.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/widgets/break_time_picker.dart';
import 'package:krow/features/shifts/presentation/dialogs/complete_dialog/widgets/complete_reason_input.dart';
class CompleteDialogView extends StatelessWidget {
CompleteDialogView({super.key, this.canSkip = true, required this.eventName});
final _textEditingController = TextEditingController();
final bool canSkip;
final String eventName;
String _title(BreakStatus state) => state == BreakStatus.negative
? 'help_us_understand'.tr()
: 'did_you_take_a_break'.tr();
String _message(BreakStatus state) => state == BreakStatus.negative
? 'taking_breaks_essential'.tr()
: 'taking_regular_breaks'.tr(namedArgs: {'eventName': eventName});
@override
Widget build(BuildContext context) {
return BlocBuilder<CompleteDialogBloc, CompleteDialogState>(
builder: (context, state) {
return Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
),
child: SingleChildScrollView(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_title(state.status),
style: AppTextStyles.headingH1.copyWith(height: 1),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
_message(state.status),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
const Gap(8),
if (state.status == BreakStatus.positive)
BreakTimePicker(state: state),
if (state.status == BreakStatus.negative)
CompleteReasonInput(selectedReason: state.selectedReason),
if (state.status == BreakStatus.negative) ...[
const Gap(8),
KwTextInput(
controller: _textEditingController,
minHeight: 144,
maxLength: 300,
showCounter: true,
radius: 12,
title: 'additional_reasons'.tr(),
hintText: 'enter_main_text'.tr(),
),
],
const Gap(24),
_buttonGroup(context, state),
],
),
),
),
),
);
},
);
}
Widget _buttonGroup(BuildContext context, CompleteDialogState state) {
var cancelButton = canSkip
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: KwButton.outlinedPrimary(
label: 'cancel'.tr(),
onPressed: () {
Navigator.pop(context);
},
),
)
: const SizedBox.shrink();
return Column(
children: [
if (state.status == BreakStatus.neutral) ...[
KwButton.primary(
label: 'yes_i_took_a_break'.tr(),
onPressed: () => context
.read<CompleteDialogBloc>()
.add(SelectBreakStatus(BreakStatus.positive)),
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'no_i_didnt_take_a_break'.tr(),
onPressed: () => context
.read<CompleteDialogBloc>()
.add(SelectBreakStatus(BreakStatus.negative)),
),
],
if (state.status == BreakStatus.positive) ...[
KwButton.primary(
disabled: state.breakTimeInputError != null,
label: 'submit_break_time'.tr(),
onPressed: () {
Navigator.pop(context, <String, dynamic>{
'result': true,
'details': ClockOutDetails.positive(
state.startTime,
state.endTime,
)
});
},
),
cancelButton,
],
if (state.status == BreakStatus.negative) ...[
KwButton.primary(
disabled: state.selectedReason == null,
label: 'submit_reason'.tr(),
onPressed: () {
Navigator.pop(context, {
'result': false,
'details': ClockOutDetails.negative(
state.selectedReason!, _textEditingController.text)
});
},
),
cancelButton,
]
],
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_menu.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/complete_dialog/shift_complete_dialog_event.dart';
const reasons = {
'unpredictable_workflows': 'unpredictable_workflows',
'poor_time_management': 'poor_time_management',
'lack_of_coverage_or_short_staff': 'lack_of_coverage_or_short_staff',
'no_break_area': 'no_break_area',
'other': 'other',
};
class CompleteReasonInput extends StatelessWidget {
final String? selectedReason;
const CompleteReasonInput({super.key, required this.selectedReason});
@override
Widget build(BuildContext context) {
return Column(
children: buildReasonInput(context, selectedReason),
);
}
List<Widget> buildReasonInput(BuildContext context, String? selectedReason) {
return [
const Gap(24),
Row(
children: [
const Gap(16),
Text(
'reason'.tr(),
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
],
),
const Gap(4),
KwPopupMenu(
horizontalPadding: 40,
fit: KwPopupMenuFit.expand,
customButtonBuilder: (context, isOpened) {
return _buildMenuButton(isOpened, selectedReason);
},
menuItems: [
...reasons.entries
.map((e) => _buildMenuItem(context, e, selectedReason ?? ''))
])
];
}
Container _buildMenuButton(bool isOpened, String? selectedReason) {
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isOpened ? AppColors.bgColorDark : AppColors.grayTintStroke,
width: 1),
),
child: Row(
children: [
Expanded(
child: Text(
reasons[selectedReason]?.tr() ?? 'select_reason_from_list'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: selectedReason == null
? AppColors.blackGray
: AppColors.blackBlack),
)),
],
),
);
}
KwPopupMenuItem _buildMenuItem(BuildContext context,
MapEntry<String, String> entry, String selectedReason) {
return KwPopupMenuItem(
title: entry.value.tr(),
onTap: () {
context.read<CompleteDialogBloc>().add(SelectReason(entry.key));
},
icon: Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: selectedReason != entry.key ? null : AppColors.bgColorDark,
shape: BoxShape.circle,
border: selectedReason == entry.key
? null
: Border.all(color: AppColors.grayTintStroke, width: 1),
),
child: selectedReason == entry.key
? Center(
child: Assets.images.icons.check.svg(
height: 10,
width: 10,
colorFilter: const ColorFilter.mode(
AppColors.grayWhite, BlendMode.srcIn),
))
: null,
),
textStyle: AppTextStyles.bodySmallMed,
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/shifts/presentation/dialogs/decline_dialog/decline_reason_dropdown.dart';
class DeclineDialogView extends StatefulWidget {
const DeclineDialogView({super.key});
@override
State<DeclineDialogView> createState() => _DeclineDialogViewState();
}
class _DeclineDialogViewState extends State<DeclineDialogView> {
String selectedReason = '';
final _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'decline_alert'.tr(),
style: AppTextStyles.headingH1.copyWith(height: 1),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
'mention_reason_declining'.tr(),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
const Gap(8),
DeclineReasonDropdown(
selectedReason: selectedReason,
onReasonSelected: (String reason) {
setState(() {
selectedReason = reason;
});
},
),
const Gap(8),
KwTextInput(
controller: _textEditingController,
minHeight: 144,
maxLength: 300,
showCounter: true,
radius: 12,
title: 'additional_reasons'.tr(),
hintText: 'enter_main_text'.tr(),
),
const Gap(24),
_buttonGroup(context),
],
),
),
),
),
);
}
Widget _buttonGroup(
BuildContext context,
) {
return Column(
children: [
KwButton.primary(
disabled: selectedReason.isEmpty,
label: 'agree_and_close',
onPressed: () {
context.maybePop({
'reason': selectedReason,
'additionalReason': _textEditingController.text,
});
},
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'contact_admin',
onPressed: () {
//todo contact admin
},
),
],
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_menu.dart';
const reasons = {
'health': 'health',
'transportation': 'transportation',
'personal': 'personal',
'schedule_conflict': 'schedule_conflict',
'other': 'other_specify',
};
class DeclineReasonDropdown extends StatelessWidget {
final String? selectedReason;
final Function(String reason) onReasonSelected;
const DeclineReasonDropdown(
{super.key,
required this.selectedReason,
required this.onReasonSelected});
@override
Widget build(BuildContext context) {
return Column(
children: buildReasonInput(context, selectedReason),
);
}
List<Widget> buildReasonInput(BuildContext context, String? selectedReason) {
return [
const Gap(24),
Row(
children: [
const Gap(16),
Text(
'valid_reasons'.tr(),
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
],
),
const Gap(4),
KwPopupMenu(
horizontalPadding: 40,
fit: KwPopupMenuFit.expand,
customButtonBuilder: (context, isOpened) {
return _buildMenuButton(isOpened, selectedReason);
},
menuItems: [
...reasons.entries
.map((e) => _buildMenuItem(context, e, selectedReason ?? ''))
])
];
}
Container _buildMenuButton(bool isOpened, String? selectedReason) {
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isOpened ? AppColors.bgColorDark : AppColors.grayTintStroke,
width: 1),
),
child: Row(
children: [
Expanded(
child: Text(
reasons[selectedReason]?.tr() ?? 'select_reason_from_list'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: selectedReason == null
? AppColors.blackGray
: AppColors.blackBlack),
)),
],
),
);
}
KwPopupMenuItem _buildMenuItem(BuildContext context,
MapEntry<String, String> entry, String selectedReason) {
return KwPopupMenuItem(
title: entry.value.tr(),
onTap: () {
onReasonSelected(entry.key);
},
icon: Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: selectedReason != entry.key ? null : AppColors.bgColorDark,
shape: BoxShape.circle,
border: selectedReason == entry.key
? null
: Border.all(color: AppColors.grayTintStroke, width: 1),
),
child: selectedReason == entry.key
? Center(
child: Assets.images.icons.check.svg(
height: 10,
width: 10,
colorFilter: const ColorFilter.mode(
AppColors.grayWhite, BlendMode.srcIn),
))
: null,
),
textStyle: AppTextStyles.bodySmallMed,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:krow/features/shifts/presentation/dialogs/decline_dialog/decline_dialog_view.dart';
class ShiftDeclineDialog extends StatelessWidget {
const ShiftDeclineDialog({super.key});
static Future<Map<String, dynamic>?> showCustomDialog(
BuildContext context) async {
return await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => const ShiftDeclineDialog(),
);
}
@override
Widget build(BuildContext context) {
return const DeclineDialogView();
}
}

View File

@@ -0,0 +1,107 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/features/shifts/domain/blocs/shift_deteils_bloc/shift_details_bloc.dart';
class GeocodingDialogs {
static bool dialogAlreadyOpen = false;
static void showGeocodingErrorDialog(
ShiftDetailsState state, BuildContext context) async{
if (dialogAlreadyOpen) {
return;
}
dialogAlreadyOpen = true;
var future;
switch (state.proximityState) {
case GeofencingProximityState.tooFar:
future = KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.warning,
title: "You're too far",
message: 'Please move closer to the designated location.',
primaryButtonLabel: 'OK',
onPrimaryButtonPressed: (dialogContext) {
dialogContext.router.maybePop();
dialogAlreadyOpen = false;
},
);
break;
case GeofencingProximityState.locationDisabled:
future = KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'Location Disabled',
message: 'Please enable location services to continue.',
primaryButtonLabel: 'Go to Settings',
onPrimaryButtonPressed: (dialogContext) async {
dialogContext.router.maybePop();
dialogAlreadyOpen = false;
await Geolocator.openLocationSettings();
},
);
break;
case GeofencingProximityState.goToSettings:
future = KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.info,
title: 'Permission Required',
message: 'You need to allow location access in settings.',
primaryButtonLabel: 'Open Settings',
onPrimaryButtonPressed: (dialogContext) async {
dialogContext.maybePop();
dialogAlreadyOpen = false;
await Geolocator.openLocationSettings();
},
);
break;
case GeofencingProximityState.onlyInUse:
future = KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.info,
title: 'Track "All the time" required',
message:
'To ensure accurate time tracking, we need access to your location. Time tracking will automatically stop if you move more than 500 meters away from your assigned work location. '
'Please grant “Allow all the time” access to your location. '
'Go to Settings → Permissions → Location and select “Allow all the time”.',
primaryButtonLabel: 'Open Settings',
onPrimaryButtonPressed: (dialogContext) async {
dialogContext.maybePop();
dialogAlreadyOpen = false;
await Geolocator.openLocationSettings();
},
);
break;
case GeofencingProximityState.permissionDenied:
future = KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'Permission Denied',
message:
'You have denied location access. Please allow it manually.',
primaryButtonLabel: 'OK',
onPrimaryButtonPressed: (dialogContext) async {
dialogContext.maybePop();
dialogAlreadyOpen = false;
await Geolocator.openAppSettings();
});
break;
default:
dialogAlreadyOpen = false;
break;
}
await future;
dialogAlreadyOpen = false;
}
}

View File

@@ -0,0 +1,212 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/blocs/shift_deteils_bloc/shift_details_bloc.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/dialogs/geocoding_dialogs.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_buttons_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_info_time_row/shift_info_clock_time_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_info_time_row/shift_info_planing_duration_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_info_time_row/shift_info_start_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_info_time_row/shift_info_total_duration_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_key_responsibilities_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_location_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_manage_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_payment_step_card_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_rating_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_timer_widgets/shift_timer_card_widget.dart';
@RoutePage()
class ShiftDetailsScreen extends StatefulWidget implements AutoRouteWrapper {
final ShiftEntity shift;
const ShiftDetailsScreen({super.key, required this.shift});
@override
State<ShiftDetailsScreen> createState() => _ShiftDetailsScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider<ShiftDetailsBloc>(
create: (context) =>
ShiftDetailsBloc()..add(ShiftDetailsInitialEvent(shift: shift)),
child: this,
);
}
}
class _ShiftDetailsScreenState extends State<ShiftDetailsScreen> with WidgetsBindingObserver{
final OverlayPortalController _controller = OverlayPortalController();
var expanded = false;
String _getTitle(EventShiftRoleStaffStatus status) {
switch (status) {
//do not use enum name. Its need for future localization;
case EventShiftRoleStaffStatus.assigned:
return 'assigned';
case EventShiftRoleStaffStatus.confirmed:
return 'confirmed';
case EventShiftRoleStaffStatus.ongoing:
return 'active';
case EventShiftRoleStaffStatus.completed:
return 'completed';
case EventShiftRoleStaffStatus.declineByStaff:
return 'declined';
case EventShiftRoleStaffStatus.canceledByStaff:
case EventShiftRoleStaffStatus.canceledByBusiness:
case EventShiftRoleStaffStatus.canceledByAdmin:
case EventShiftRoleStaffStatus.requestedReplace:
return 'canceled';
}
}
bool _showButtons(EventShiftRoleStaffStatus status) =>
status == EventShiftRoleStaffStatus.assigned || status == EventShiftRoleStaffStatus.confirmed;
bool showTimer(EventShiftRoleStaffStatus status) =>
status == EventShiftRoleStaffStatus.confirmed ||
status == EventShiftRoleStaffStatus.ongoing;
void _listenHandler(BuildContext context, ShiftDetailsState state) {
if (state.needPop) {
context.router.maybePop();
return;
}
if (state.isLoading) {
_controller.show();
} else {
_controller.hide();
}
if(state.errorMessage!=null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
),
);
}
if (state.proximityState == GeofencingProximityState.none) return;
GeocodingDialogs.showGeocodingErrorDialog(state, context);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
expanded = widget.shift.status != EventShiftRoleStaffStatus.completed;
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
BlocProvider.of<ShiftDetailsBloc>(context).add(const ShiftCheckGeocodingEvent());
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ShiftDetailsBloc, ShiftDetailsState>(
buildWhen: (previous, current) =>
previous.shiftViewModel != current.shiftViewModel,
listenWhen: (previous, current) =>
previous.isLoading != current.isLoading ||
previous.proximityState != current.proximityState || current.errorMessage!=null,
listener: _listenHandler,
builder: (context, state) {
var viewModel = state.shiftViewModel;
var status = viewModel.status;
return KwLoadingOverlay(
controller: _controller,
child: Scaffold(
appBar: KwAppBar(
titleText: '${_getTitle(status).tr()} ${'Shift'.tr()}',
showNotification: true,
),
body: Stack(
children: [
ListView(
padding: const EdgeInsets.only(top: 16),
children: [
ShiftItemWidget(
bottomPadding: 0,
viewModel,
isDetailsMode: true,
),
if (showTimer(viewModel.status))
const ShiftTimerCardWidget(),
_buildShiftTimeInfo(viewModel),
if (status == EventShiftRoleStaffStatus.completed)
ShiftPaymentStepCardWidget(viewModel: viewModel),
if (status == EventShiftRoleStaffStatus.completed &&
viewModel.rating != null)
ShiftRatingWidget(viewModel: viewModel),
if (status != EventShiftRoleStaffStatus.ongoing)
ShiftLocationWidget(viewModel: viewModel),
ShiftManageWidget(managers: viewModel.managers),
if (viewModel.additionalInfo != null)
ShiftKeyResponsibilitiesWidget(
text: viewModel.additionalInfo!,
expandable:
status == EventShiftRoleStaffStatus.completed,
isExpanded: expanded,
onTap: () {
setState(() {
expanded = !expanded;
});
},
),
SizedBox(height: MediaQuery.sizeOf(context).height / 3),
],
),
if (_showButtons(viewModel.status))
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ShiftButtonsWidget(
viewModel.status,
),
),
],
),
),
);
},
);
}
Widget _buildShiftTimeInfo(ShiftEntity shiftViewModel) {
return Container(
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: Row(
children: [
shiftViewModel.status == EventShiftRoleStaffStatus.completed
? ShiftInfoClockTimeWidget(viewModel: shiftViewModel)
: ShiftInfoStartWidget(viewModel: shiftViewModel),
const Gap(8),
shiftViewModel.status == EventShiftRoleStaffStatus.completed
? ShiftInfoTotalDurationWidget(viewModel: shiftViewModel)
: ShiftInfoPlaningDurationWidget(viewModel: shiftViewModel)
],
),
);
}
}

View File

@@ -0,0 +1,204 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_tabs.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_event.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_state.dart';
import 'package:krow/features/shifts/domain/services/shift_completer_service.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_widget.dart';
@RoutePage()
class ShiftsListMainScreen extends StatefulWidget {
const ShiftsListMainScreen({super.key});
@override
State<ShiftsListMainScreen> createState() => _ShiftsListMainScreenState();
}
class _ShiftsListMainScreenState extends State<ShiftsListMainScreen> {
late AppLifecycleListener _appLifecycleListener;
var dialogOpened = false;
final List<String> tabs = [
'assigned',
'confirmed',
'active',
'completed',
'canceled'
];
late ScrollController _scrollController;
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
BlocProvider.of<ShiftsBloc>(context).add(
LoadTabShiftEvent(
status: BlocProvider.of<ShiftsBloc>(context).state.tabIndex),
);
BlocProvider.of<ShiftsBloc>(context).add(
const ReloadMissingBreakShift(),
);
});
}
@override
void initState() {
super.initState();
_appLifecycleListener = AppLifecycleListener(onStateChange: (state) {
if (state == AppLifecycleState.resumed) {
BlocProvider.of<ShiftsBloc>(context).add(
const ReloadMissingBreakShift(),
);
}
});
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_appLifecycleListener.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels != 0) {
BlocProvider.of<ShiftsBloc>(context).add(
LoadMoreShiftEvent(
status: BlocProvider.of<ShiftsBloc>(context).state.tabIndex),
);
}
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ShiftsBloc, ShiftsState>(
listenWhen: (oldState, state) {
return state.missedShifts.isNotEmpty &&
(oldState.missedShifts.isEmpty ||
oldState.missedShifts.first.id != state.missedShifts.first.id);
},
listener: (context, state) async {
if (state.missedShifts.isNotEmpty) {
if(dialogOpened) return;
dialogOpened = true;
await ShiftCompleterService().startCompleteProcess(
context,
canSkip: false,
state.missedShifts.first,
onComplete: () {},
);
dialogOpened = false;
BlocProvider.of<ShiftsBloc>(context).add(
const ReloadMissingBreakShift(),
);
}
},
builder: (context, state) {
List<ShiftEntity> items = state.tabs[state.tabIndex]!.items;
return Scaffold(
appBar: KwAppBar(
titleText: 'your_shifts'.tr(),
showNotification: true,
centerTitle: false,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.symmetric(vertical: 16),
onRefresh: () async {
BlocProvider.of<ShiftsBloc>(context)
.add(const ReloadMissingBreakShift());
BlocProvider.of<ShiftsBloc>(context)
.add(LoadTabShiftEvent(status: state.tabIndex));
},
controller: _scrollController,
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
KwTabBar(
key: const Key('shifts_tab_bar'),
tabs: tabs.map((e) => e.tr()).toList(),
onTap: (index) {
BlocProvider.of<ShiftsBloc>(context)
.add(ShiftsTabChangedEvent(tabIndex: index));
}),
const Gap(16),
if (state.tabs[state.tabIndex]!.isLoading &&
state.tabs[state.tabIndex]!.items.isEmpty)
..._buildListLoading(),
if (!state.tabs[state.tabIndex]!.isLoading && items.isEmpty)
..._emptyListWidget(),
RefreshIndicator(
onRefresh: () async {
BlocProvider.of<ShiftsBloc>(context)
.add(LoadTabShiftEvent(status: state.tabIndex));
},
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) {
return ShiftItemWidget(
items[index],
bottomPadding: 12,
onPressed: () {
context.pushRoute(
ShiftDetailsRoute(shift: items[index]),
);
},
);
}),
),
],
),
lowerWidget: const SizedBox.shrink(),
),
);
},
);
}
List<Widget> _buildListLoading() {
return [
const Gap(116),
const Center(child: CircularProgressIndicator()),
];
}
List<Widget> _emptyListWidget() {
return [
const Gap(100),
Container(
height: 64,
width: 64,
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(32),
),
child: Center(child: Assets.images.icons.xCircle.svg()),
),
const Gap(24),
Text(
'you_currently_have_no_shifts'.tr(),
textAlign: TextAlign.center,
style: AppTextStyles.headingH2,
),
];
}
}

View File

@@ -0,0 +1,19 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_bloc.dart';
import 'package:krow/features/shifts/domain/blocs/shifts_list_bloc/shifts_event.dart';
@RoutePage()
class ShiftsFlowScreen extends StatelessWidget {
const ShiftsFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(providers: [
BlocProvider<ShiftsBloc>(
create: (context) => ShiftsBloc()..add(const ShiftsInitialEvent()),
),
], child: const AutoRouter());
}
}

View File

@@ -0,0 +1,76 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/blocs/shift_deteils_bloc/shift_details_bloc.dart';
import 'package:krow/features/shifts/presentation/dialogs/cancel_dialog/shift_cancel_dialog.dart';
import 'package:krow/features/shifts/presentation/dialogs/decline_dialog/shift_decline_dialog.dart';
class ShiftButtonsWidget extends StatelessWidget {
final EventShiftRoleStaffStatus status;
const ShiftButtonsWidget(this.status, {super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: const Alignment(0, -0.5),
colors: [
AppColors.grayWhite,
AppColors.grayWhite.withAlpha(0),
],
),
),
child: SafeArea(
child: Column(
children: status == EventShiftRoleStaffStatus.assigned
? [
KwButton.primary(
label: 'accept_shift'.tr(),
onPressed: () {
BlocProvider.of<ShiftDetailsBloc>(context).add(
const ShiftConfirmEvent(),
);
},
fit: KwButtonFit.expanded,
),
const Gap(12),
KwButton.outlinedPrimary(
label: 'decline_shift'.tr(),
onPressed: () async {
var result =
await ShiftDeclineDialog.showCustomDialog(context);
if (result != null && context.mounted) {
BlocProvider.of<ShiftDetailsBloc>(context).add(
ShiftDeclineEvent(
result['reason'], result['additionalReason']),
);
}
}).copyWith(color: AppColors.statusError)
]
: [
KwButton.outlinedPrimary(
label: 'cancel_shift'.tr(),
onPressed: () async {
var result =
await ShiftCancelDialog.showCustomDialog(context);
if (result != null && context.mounted) {
BlocProvider.of<ShiftDetailsBloc>(context).add(
ShiftCancelEvent(
result['reason'], result['additionalReason']),
);
}
}).copyWith(color: AppColors.statusError)
],
),
),
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftInfoClockTimeWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftInfoClockTimeWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
var clockIn = DateFormat('h:mma','en')
.format(viewModel.clockIn ?? DateTime.now())
.toLowerCase();
var clockOut = DateFormat('h:mma','en')
.format(viewModel.clockOut ?? DateTime.now())
.toLowerCase();
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${'clock_in_1'.tr()} - ${'clock_out_1'.tr()}'.toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText)),
const Gap(16),
Text(clockIn, style: AppTextStyles.headingH3),
const Gap(6),
Text('clock_in_1'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
Text(clockOut, style: AppTextStyles.headingH3),
const Gap(6),
Text('clock_out_1'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
],
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftInfoPlaningDurationWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftInfoPlaningDurationWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
var formattedDuration =
_format(viewModel.endDate.difference(viewModel.startDate).inMinutes);
var formattedBreak = _format(viewModel.planingBreakTime!);
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('duration'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText)),
const Gap(16),
Text(formattedDuration, style: AppTextStyles.headingH3),
const Gap(6),
Text('shift_duration'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
Text(formattedBreak, style: AppTextStyles.headingH3),
const Gap(6),
Text('break_duration'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
],
),
),
);
}
String _format(int? duration) {
if (duration == null) return '0 hours';
var hours = duration ~/ 60;
var minutes = duration % 60;
var hoursStr = 'hours_1'.tr(namedArgs: {'hours':hours.toString(),'plural':hours == 1 ? '' : 's'});
return minutes == 0 ? hoursStr : 'hours_minutes'.tr(namedArgs: {'hours':hours.toString(),'minutes':minutes.toString()});
}
}

View File

@@ -0,0 +1,48 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftInfoStartWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftInfoStartWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
var dateTime = viewModel.startDate;
var date = DateFormat('MMMM d', context.locale.languageCode).format(dateTime);
var time = DateFormat('h:mma', 'en').format(dateTime).toLowerCase();
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('start'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText)),
const Gap(16),
Text(date, style: AppTextStyles.headingH3),
const Gap(6),
Text('date'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
Text(time, style: AppTextStyles.headingH3),
const Gap(6),
Text('time'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
],
),
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftInfoTotalDurationWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftInfoTotalDurationWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
var formattedDuration;
if (viewModel.clockOut == null || viewModel.clockIn == null) {
formattedDuration = '00:00:00';
}
formattedDuration = _format(viewModel.clockOut
?.difference(viewModel.clockIn ?? DateTime.now())
.inSeconds ??
0);
var formattedBreak = _format(viewModel.totalBreakTime!);
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('total_time_breaks'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText)),
const Gap(16),
Text(formattedBreak, style: AppTextStyles.headingH3),
const Gap(6),
Text('break_hours'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
Text(formattedDuration, style: AppTextStyles.headingH3),
const Gap(6),
Text('total_hours'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
],
),
),
);
}
String _format(int duration) {
var hours = (duration ~/ 3600).toString().padLeft(2, '0');
var minutes = ((duration % 3600) ~/ 60).toString().padLeft(2, '0');
var seconds = (duration % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
}

View File

@@ -0,0 +1,99 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/data/models/cancellation_reason.dart';
class ShiftItemCanceledWidget extends StatelessWidget {
final CancellationReason? reason;
final bool canceledByUser;
const ShiftItemCanceledWidget(
{super.key, required this.reason, required this.canceledByUser});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
decoration:
KwBoxDecorations.primaryLight8.copyWith(color: AppColors.tintBlue),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'your_shift_canceled'.tr(),
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.primaryBlue),
),
],
),
const Gap(8),
Text(
'please_review_reason'.tr(),
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.primaryBlue)),
const Gap(8),
Row(
children: [
Text(
'canceled_by'.tr(),
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.tintDarkBlue),
),
const Gap(4),
Text(
canceledByUser ? 'user'.tr() : 'admin'.tr(),
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.primaryBlue),
),
],
),
const Gap(8),
Container(
padding: const EdgeInsets.all(12.0),
decoration: KwBoxDecorations.primaryLight6,
child: Row(
children: [
Text(
'${'reason'.tr()}:',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
),
const Gap(4),
Text(reasonToString(reason).tr(),
style: AppTextStyles.bodyMediumMed),
],
),
),
],
),
);
}
String reasonToString(CancellationReason? reason) {
switch (reason) {
case CancellationReason.sickLeave:
return 'sick_leave';
case CancellationReason.vacation:
return 'vacation';
case CancellationReason.other:
return 'Other';
case CancellationReason.health:
return 'health';
case CancellationReason.transportation:
return 'transportation';
case CancellationReason.personal:
return 'personal';
case CancellationReason.scheduleConflict:
return 'schedule_conflict';
case null:
return 'other';
}
}
}

View File

@@ -0,0 +1,106 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftAdditionalDetails extends StatefulWidget {
final ShiftEntity viewModel;
const ShiftAdditionalDetails({super.key, required this.viewModel});
@override
State<ShiftAdditionalDetails> createState() => _ShiftAdditionalDetailsState();
}
class _ShiftAdditionalDetailsState extends State<ShiftAdditionalDetails> {
bool expanded = false;
@override
Widget build(BuildContext context) {
if (widget.viewModel.additionalData?.isEmpty ?? true) {
return const Gap(12);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap(12),
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
height: 1,
color: AppColors.grayTintStroke,
),
const Gap(12),
GestureDetector(
onTap: () {
setState(() {
expanded = !expanded;
});
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
height: 16,
color: Colors.transparent,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('additional_details'.tr(),
style: AppTextStyles.captionReg),
const Spacer(),
AnimatedRotation(
duration: const Duration(milliseconds: 150),
turns: expanded ? -0.5 : 0,
child: Assets.images.icons.caretDown.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.blackBlack, BlendMode.srcIn))),
],
),
),
),
if (widget.viewModel.additionalData?.isNotEmpty ?? false) ...[
const Gap(12),
_buildExpandedDetails(),
]
],
);
}
_buildExpandedDetails() {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
clipBehavior: Clip.antiAlias,
height: expanded ? widget.viewModel.additionalData!.length * 28 + 12 : 0,
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 12),
decoration: const BoxDecoration(
color: AppColors.graySecondaryFrame,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...widget.viewModel.additionalData?.map(
(e) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Text(e.name ?? '',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
const Spacer(),
Text('yes'.tr(), style: AppTextStyles.bodySmallReg),
],
),
);
},
) ??
[],
],
),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_status_label_widget.dart';
class ShiftItemHeaderWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftItemHeaderWidget(this.viewModel, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
viewModel.imageUrl.isNotEmpty
? ClipOval(
child: CachedNetworkImage(
imageUrl: viewModel.imageUrl,
fit: BoxFit.cover,
height: 48,
width: 48,
))
: const SizedBox.shrink(),
ShiftStatusLabelWidget(viewModel),
],
),
const Gap(12),
Row(
children: [
Expanded(
child: Text(
viewModel.skillName,
style: AppTextStyles.bodyMediumMed,
)),
const Gap(24),
Text(
'\$${viewModel.rate}/h',
style: AppTextStyles.bodyMediumMed,
)
],
),
const Gap(4),
Text(
viewModel.eventName,
style:
AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
),
],
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
Timer? shiftListTimer;
class ShiftOngoingCounterWidget extends StatefulWidget {
final ShiftEntity viewModel;
const ShiftOngoingCounterWidget({super.key, required this.viewModel});
@override
State<ShiftOngoingCounterWidget> createState() =>
_ShiftOngoingCounterWidgetState();
}
class _ShiftOngoingCounterWidgetState extends State<ShiftOngoingCounterWidget> {
@override
void initState() {
super.initState();
shiftListTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
var duration =
DateTime.now().difference(widget.viewModel.clockIn ?? DateTime.now());
var hours = duration.inHours.remainder(24).abs().toString().padLeft(2, '0');
var minutes =
duration.inMinutes.remainder(60).abs().toString().padLeft(2, '0');
var seconds =
duration.inSeconds.remainder(60).abs().toString().padLeft(2, '0');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 80,
margin: const EdgeInsets.only(top: 24, left: 24, right: 24),
decoration: BoxDecoration(
color: AppColors.tintGreen,
border: Border.all(color: AppColors.tintDarkGreen),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTimeText('hours'.tr(), hours),
_divider(),
_buildTimeText('minutes'.tr(), minutes),
_divider(),
_buildTimeText('seconds'.tr(), seconds),
],
),
),
],
);
}
Widget _divider() {
return SizedBox(
width: 24,
child: Center(
child: Container(
height: 24,
width: 1,
color: AppColors.tintDarkGreen,
),
),
);
}
Widget _buildTimeText(String label, String time) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(time,
style: AppTextStyles.headingH3
.copyWith(color: AppColors.statusSuccess)),
const Gap(6),
Text(
label,
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.statusSuccess),
),
],
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftPlaceAndTimeWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftPlaceAndTimeWidget(this.viewModel, {super.key});
@override
Widget build(BuildContext context) {
final timeFormat = DateFormat('MMMM d, h:mma', context.locale.languageCode);
return Stack(
children: [
SizedBox(
height: 28,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Container(
constraints: BoxConstraints(
minWidth: MediaQuery.of(context).size.width - 32),
child: IntrinsicWidth(
child: Row(
children: [
const Gap(16),
Assets.images.icons.location.svg(width: 16, height: 16),
const Gap(4),
Text(
viewModel.locationName,
style: AppTextStyles.bodySmallReg,
),
const Spacer(),
const Gap(12),
Assets.images.icons.calendar.svg(width: 16, height: 16),
const Gap(4),
Text(
timeFormat.format(viewModel.startDate),
style: AppTextStyles.bodySmallReg,
),
const Gap(16),
],
),
),
),
);
},
),
),
Positioned(
right: 0,
child: Container(
height: 28,
width: 20,
decoration: BoxDecoration(
color: Colors.red,
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
AppColors.grayPrimaryFrame.withAlpha(50),
AppColors.grayPrimaryFrame.withAlpha(255),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftStatusLabelWidget extends StatefulWidget {
final ShiftEntity viewModel;
const ShiftStatusLabelWidget(this.viewModel, {super.key});
@override
State<ShiftStatusLabelWidget> createState() => _ShiftStatusLabelWidgetState();
}
class _ShiftStatusLabelWidgetState extends State<ShiftStatusLabelWidget> {
Color getColor() {
switch (widget.viewModel.status) {
case EventShiftRoleStaffStatus.assigned:
return AppColors.primaryBlue;
case EventShiftRoleStaffStatus.confirmed:
return AppColors.statusWarning;
case EventShiftRoleStaffStatus.ongoing:
return AppColors.statusSuccess;
case EventShiftRoleStaffStatus.completed:
return AppColors.bgColorDark;
case EventShiftRoleStaffStatus.canceledByAdmin:
case EventShiftRoleStaffStatus.canceledByBusiness:
case EventShiftRoleStaffStatus.canceledByStaff:
case EventShiftRoleStaffStatus.requestedReplace:
case EventShiftRoleStaffStatus.declineByStaff:
return AppColors.statusError;
}
}
String getText() {
switch (widget.viewModel.status) {
case EventShiftRoleStaffStatus.assigned:
return _getAssignedAgo();
case EventShiftRoleStaffStatus.confirmed:
return _getStartIn();
case EventShiftRoleStaffStatus.ongoing:
return 'ongoing'.tr();
case EventShiftRoleStaffStatus.completed:
return 'completed'.tr();
case EventShiftRoleStaffStatus.declineByStaff:
return 'declined'.tr();
case EventShiftRoleStaffStatus.canceledByAdmin:
case EventShiftRoleStaffStatus.canceledByBusiness:
case EventShiftRoleStaffStatus.canceledByStaff:
case EventShiftRoleStaffStatus.requestedReplace:
return 'canceled'.tr();
}
}
String _getStartIn() {
var duration = widget.viewModel.startDate.difference(DateTime.now());
var startIn = '';
if (duration.inMinutes < 0) {
return 'started'.tr();
}
if (duration.inDays > 0) {
startIn = '${duration.inDays}d ${duration.inHours.remainder(24)}h';
} else if (duration.inHours.abs() > 0) {
startIn = '${duration.inHours}h ${duration.inMinutes.remainder(60)}min';
} else {
startIn = '${duration.inMinutes}min';
}
return 'starts_in'.tr(namedArgs: {'time': startIn});
}
String _getAssignedAgo() {
var duration = DateTime.now().difference(widget.viewModel.assignedDate);
var timeAgo = '';
if (duration.inDays > 0) {
timeAgo = '${duration.inDays}d ago';
} else if (duration.inHours > 0) {
timeAgo = '${duration.inHours}h ago';
} else {
timeAgo = '${duration.inMinutes}m ago';
}
return 'assigned_ago'.tr(namedArgs: {'time': timeAgo});
}
@override
Widget build(BuildContext context) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: getColor(),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
getText(),
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.grayWhite, height: 0.7),
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/data/models/event_tag.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftTagsWidget extends StatelessWidget {
final ShiftEntity viewModel;
final bool scrollable;
const ShiftTagsWidget(this.viewModel, {this.scrollable = false, super.key});
final textColors = const {
'1': AppColors.statusWarningBody,
'2': AppColors.statusError,
'3': AppColors.statusSuccess,
};
final backGroundsColors = const {
'1': AppColors.tintYellow,
'2': AppColors.tintRed,
'3': AppColors.tintGreen,
};
@override
Widget build(BuildContext context) {
if (viewModel.tags == null || viewModel.tags!.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
const Gap(12),
if (scrollable) _buildScrollableList(),
if (!scrollable) _buildChips(),
],
);
}
Widget _buildScrollableList() {
return SizedBox(
height: 44,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16, right: 12),
itemCount: viewModel.tags?.length ?? 0,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return _buildTag(viewModel.tags![index]);
},
),
);
}
Widget _buildChips() {
return Padding(
padding: const EdgeInsets.only(left: 16, right: 12),
child: Wrap(
runSpacing: 4,
children: viewModel.tags!.map((e) => _buildTag(e)).toList(),
),
);
}
Widget _buildTag(EventTag shiftTage) {
return Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.all(8),
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: backGroundsColors[shiftTage.id]??AppColors.tintGreen,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 28,
width: 28,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.grayWhite,
),
child: Center(
child: Assets.images.icons.eye.svg(
width: 12,
height: 12,
colorFilter: ColorFilter.mode(
textColors[shiftTage.id] ?? AppColors.statusWarningBody,
BlendMode.srcIn)),
),
),
const Gap(8),
Text(
shiftTage.name,
style: AppTextStyles.bodySmallReg
.copyWith(color: textColors[shiftTage.id]??AppColors.statusSuccess),
),
const Gap(8),
],
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/widgets/shift_payment_step_widget.dart';
import 'package:krow/core/presentation/widgets/shift_total_time_spend_widget.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_canceled_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_additional_details_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_item_header_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_ongoing_counter_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_place_and_time_widget.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_item_components/shift_tags_widget.dart';
class ShiftItemWidget extends StatelessWidget {
final ShiftEntity viewModel;
final bool isDetailsMode;
final VoidCallback? onPressed;
final double bottomPadding;
const ShiftItemWidget(this.viewModel,
{this.bottomPadding = 0,
this.onPressed,
this.isDetailsMode = false,
super.key});
@override
Widget build(BuildContext context) {
var showPlaceAndTime = !isDetailsMode &&
(viewModel.status == EventShiftRoleStaffStatus.assigned ||
viewModel.status == EventShiftRoleStaffStatus.confirmed);
var showTags =
isDetailsMode || viewModel.status == EventShiftRoleStaffStatus.assigned;
var showOngoingCounter =
!isDetailsMode && viewModel.status == EventShiftRoleStaffStatus.ongoing;
var showTotalTimeSpend = !isDetailsMode &&
viewModel.status == EventShiftRoleStaffStatus.completed;
var showAdditionalDetails =
isDetailsMode || viewModel.status == EventShiftRoleStaffStatus.ongoing;
var canceled =
viewModel.status == EventShiftRoleStaffStatus.canceledByStaff ||
viewModel.status == EventShiftRoleStaffStatus.canceledByBusiness ||
viewModel.status == EventShiftRoleStaffStatus.canceledByAdmin;
return GestureDetector(
onTap: canceled?null:onPressed,
child: Container(
decoration: KwBoxDecorations.primaryLight12,
margin: EdgeInsets.only(bottom: bottomPadding, left: 16, right: 16),
padding:
EdgeInsets.only(top: 24, bottom: showAdditionalDetails ? 0 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShiftItemHeaderWidget(viewModel),
if (showPlaceAndTime) ShiftPlaceAndTimeWidget(viewModel),
if (showTags)
ShiftTagsWidget(viewModel, scrollable: !isDetailsMode),
if (showOngoingCounter)
ShiftOngoingCounterWidget(viewModel: viewModel),
if (showTotalTimeSpend)
ShiftTotalTimeSpendWidget(
startTime: viewModel.clockIn?? DateTime.now(),
endTime: viewModel.clockOut?? viewModel.clockIn??DateTime.now(),
totalBreakTime: viewModel.totalBreakTime ?? 0,
),
if (showTotalTimeSpend)
const ShiftPaymentStepWidget(
currentIndex: 1,
),
if (showAdditionalDetails)
ShiftAdditionalDetails(viewModel: viewModel),
if (canceled)
ShiftItemCanceledWidget(
reason: viewModel.cancellationReason,
canceledByUser: viewModel.status ==
EventShiftRoleStaffStatus.canceledByStaff),
],
),
),
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
class ShiftKeyResponsibilitiesWidget extends StatelessWidget {
final String text;
final bool isExpanded;
final bool expandable;
final VoidCallback onTap;
const ShiftKeyResponsibilitiesWidget(
{super.key,
required this.text,
required this.expandable,
required this.isExpanded,
required this.onTap});
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: const Duration(milliseconds: 300),
clipBehavior: Clip.antiAlias,
alignment: Alignment.topCenter,
child: Container(
clipBehavior: Clip.antiAlias,
height: isExpanded ? null : 40,
margin: const EdgeInsets.only(top: 8, left: 16, right: 16),
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
expandable ? expandableTitle() : fixedTitle(),
const Gap(16),
Text(
text,
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
],
),
),
);
}
Widget fixedTitle() {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${'key_responsibilities'.tr()}:'.toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText)),
]);
}
Widget expandableTitle() {
return GestureDetector(
onTap: onTap,
child: Container(
color: Colors.transparent,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('additional_information'.tr().toUpperCase(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
],
),
AnimatedRotation(
duration: const Duration(milliseconds: 150),
turns: isExpanded ? -0.5 : 0,
child: Assets.images.icons.caretDown.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.blackBlack, BlendMode.srcIn))),
],
),
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:krow/core/application/common/map_utils.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftLocationWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftLocationWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 8, left: 16, right: 16),
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'location'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
const Gap(16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
viewModel.locationName,
style: AppTextStyles.headingH3.copyWith(height: 1),
),
),
),
const Gap(16),
KwButton.outlinedPrimary(
label: 'get_direction'.tr(),
leftIcon: Assets.images.icons.routing,
onPressed: () {
MapUtils.openMapByLatLon(
viewModel.locationLat,
viewModel.locationLon,
);
},
),
],
),
if (viewModel.status != EventShiftRoleStaffStatus.completed)
_buildMap(),
],
),
);
}
Widget _buildMap() {
if (viewModel.locationLat == 0 && viewModel.locationLon == 0) {
return Container(
height: 166,
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
);
}
return Container(
margin: const EdgeInsets.only(top: 16),
height: 166,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: GoogleMap(
rotateGesturesEnabled: false,
compassEnabled: false,
zoomControlsEnabled: false,
scrollGesturesEnabled: false,
zoomGesturesEnabled: false,
tiltGesturesEnabled: false,
mapToolbarEnabled: false,
myLocationButtonEnabled: false,
markers: {
Marker(
markerId: const MarkerId('1'),
position: LatLng(viewModel.locationLat, viewModel.locationLon),
),
},
initialCameraPosition: CameraPosition(
target: LatLng(viewModel.locationLat, viewModel.locationLon),
zoom: 12,
),
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:whatsapp_unilink/whatsapp_unilink.dart';
class ShiftManageWidget extends StatelessWidget {
final List<ShiftManager> managers;
const ShiftManageWidget({super.key, required this.managers});
@override
Widget build(BuildContext context) {
if (managers.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'manage_contact_details'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
const Gap(16),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: managers.length,
itemBuilder: (context, index) {
return _buildManager(managers[index]);
},
separatorBuilder: (context, index) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(
color: AppColors.grayTintStroke,
),
);
},
),
],
),
);
}
Widget _buildManager(ShiftManager manager) {
return Row(
children: [
if(manager.imageUrl.isNotEmpty)
CircleAvatar(
radius: 24.0,
backgroundColor: AppColors.grayWhite,
backgroundImage: CachedNetworkImageProvider(
manager.imageUrl), // Replace with your image asset
),
const SizedBox(width: 16.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
manager.name,
style: AppTextStyles.bodyMediumSmb,
),
const SizedBox(height: 4.0),
Text(
manager.phoneNumber,
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
],
),
),
const SizedBox(width: 8.0),
Row(
children: [
KwButton.outlinedPrimary(
rightIcon: Assets.images.icons.whatsapp,
fit: KwButtonFit.circular,
onPressed: () {
var link = WhatsAppUnilink(
phoneNumber: manager.phoneNumber,
text: '',
);
launchUrlString(link.toString());
},
),
const SizedBox(width: 8.0),
KwButton.outlinedPrimary(
label: 'call'.tr(),
rightIcon: Assets.images.icons.call,
onPressed: () {
launchUrlString('tel:${manager.phoneNumber}');
},
).copyWith(
color: AppColors.tintDarkGreen,
textColors: AppColors.statusSuccess),
],
)
],
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/shift_payment_step_widget.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftPaymentStepCardWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftPaymentStepCardWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'payment_status'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
const ShiftPaymentStepWidget(currentIndex: 1),
],
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
class ShiftRatingWidget extends StatelessWidget {
final ShiftEntity viewModel;
const ShiftRatingWidget({super.key, required this.viewModel});
final double maxRating = 5.0; // Maximum rating, e.g., 5
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'clients_rate'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
const Gap(4),
Center(
child: Text(viewModel.rating?.rating.toStringAsFixed(1) ?? '0.0',
style: AppTextStyles.headingH0),
),
const Gap(4),
_buildRating(viewModel.rating?.rating ?? 0),
],
),
);
}
_buildRating(double rating) {
List<Widget> stars = [];
for (int i = 1; i <= maxRating; i++) {
if (i <= rating) {
stars.add(Assets.images.icons.ratingStar.star.svg());
} else if (i - rating < 1) {
stars.add(Assets.images.icons.ratingStar.starHalf.svg());
} else {
stars.add(Assets.images.icons.ratingStar.starEmpty.svg());
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [const Gap(28), ...stars, const Gap(28)],
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/common/map_utils.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
import 'package:krow/features/shifts/domain/blocs/shift_deteils_bloc/shift_details_bloc.dart';
import 'package:krow/features/shifts/domain/services/shift_completer_service.dart';
import 'package:krow/features/shifts/domain/shift_entity.dart';
import 'package:krow/features/shifts/presentation/widgets/shift_timer_widgets/shift_timer_widget.dart';
class ShiftTimerCardWidget extends StatelessWidget {
const ShiftTimerCardWidget({super.key});
bool _hasBorder(EventShiftRoleStaffStatus status) =>
status == EventShiftRoleStaffStatus.ongoing;
@override
Widget build(BuildContext context) {
return BlocBuilder<ShiftDetailsBloc, ShiftDetailsState>(
buildWhen: (previous, current) =>
previous.isToFar != current.isToFar ||
previous.shiftViewModel != current.shiftViewModel,
builder: (context, state) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(top: 8, left: 16, right: 16),
decoration: KwBoxDecorations.primaryLight12.copyWith(
border: _hasBorder(state.shiftViewModel.status)
? Border.all(color: AppColors.tintDarkGreen, width: 2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'timer'.tr().toUpperCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
const Gap(16),
ShiftTimerWidget(
shiftStatus: state.shiftViewModel.status,
clockIn: state.shiftViewModel.clockIn,
clockOut: state.shiftViewModel.clockOut,
),
_buildButton(
context,
state.isToFar,
state.shiftViewModel,
),
],
),
);
},
);
}
_buildButton(
context,
bool isToFar,
ShiftEntity viewModel,
) {
final status = viewModel.status;
return Column(
children: [
if (isToFar && status == EventShiftRoleStaffStatus.confirmed ||
status == EventShiftRoleStaffStatus.ongoing)
_toFarMessage(status),
const Gap(16),
KwButton.primary(
disabled: (status == EventShiftRoleStaffStatus.confirmed ||
status == EventShiftRoleStaffStatus.ongoing) &&
isToFar != false,
label: status == EventShiftRoleStaffStatus.confirmed
? 'clock_in'.tr()
: 'clock_out'.tr(),
onPressed: () async {
if (status == EventShiftRoleStaffStatus.confirmed) {
_onClockIn(context, viewModel.eventId);
} else {
await _onClocOut(context, viewModel.eventId, viewModel);
}
},
),
if (isToFar && status == EventShiftRoleStaffStatus.confirmed) ...[
const Gap(16),
KwButton.outlinedPrimary(
leftIcon: Assets.images.icons.routing,
label: 'get_direction'.tr(),
onPressed: () {
MapUtils.openMapByLatLon(
viewModel.locationLat,
viewModel.locationLon,
);
},
)
]
],
);
}
Widget _toFarMessage(status) {
return Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.graySecondaryFrame,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
height: 28,
width: 28,
decoration: const BoxDecoration(
color: AppColors.grayWhite,
shape: BoxShape.circle,
),
child: Assets.images.icons.alertCircle.svg(
height: 12,
width: 12,
colorFilter:
const ColorFilter.mode(AppColors.blackBlack, BlendMode.srcIn),
),
),
const Gap(8),
Expanded(
child: Text(
status == EventShiftRoleStaffStatus.ongoing
? 'reach_location_to_clock_out'.tr()
: 'reach_location_to_clock_in'.tr(),
style: AppTextStyles.bodySmallMed,
),
),
],
),
);
}
Future<void> _onClocOut(
BuildContext context, String eventId, ShiftEntity shift) async {
if (!kDebugMode) {
var qrResult = await context.router.push(const QrScannerRoute());
if (!context.mounted) return;
if (qrResult == null || qrResult != eventId) {
KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'oops_something_wrong'.tr(),
message: 'qr_code_error'.tr(),
primaryButtonLabel: 'retry_scanning'.tr(),
secondaryButtonLabel: 'cancel'.tr(),
onPrimaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
_onClocOut(context, eventId, shift);
});
return;
}
}
ShiftCompleterService().startCompleteProcess(context, shift,
onComplete: () {
BlocProvider.of<ShiftDetailsBloc>(context)
.add(const ShiftCompleteEvent());
});
}
void _onClockIn(BuildContext context, String eventId) async {
if (kDebugMode) {
BlocProvider.of<ShiftDetailsBloc>(context).add(const ShiftClockInEvent());
return;
}
var result = await context.router.push(const QrScannerRoute());
if (!context.mounted) return;
if (result != null && result == eventId) {
KwDialog.show(
context: context,
icon: Assets.images.icons.like,
state: KwDialogState.positive,
title: 'youre_good_to_go'.tr(),
message: 'shift_timer_started'.tr(),
child: Text(
'lunch_break_reminder'.tr(),
textAlign: TextAlign.center,
style: AppTextStyles.bodyMediumMed,
),
primaryButtonLabel: 'Continue to Dashboard');
BlocProvider.of<ShiftDetailsBloc>(context).add(const ShiftClockInEvent());
} else {
KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'oops_something_wrong'.tr(),
message: 'qr_code_error'.tr(),
primaryButtonLabel: 'retry_scanning'.tr(),
secondaryButtonLabel: 'cancel'.tr(),
onPrimaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
_onClockIn(context, eventId);
});
}
}
}

View File

@@ -0,0 +1,121 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/shifts/data/models/staff_shift.dart';
class ShiftTimerWidget extends StatefulWidget {
final EventShiftRoleStaffStatus shiftStatus;
final DateTime? clockIn;
final DateTime? clockOut;
const ShiftTimerWidget(
{super.key,
required this.shiftStatus,
required this.clockIn,
required this.clockOut});
@override
State<ShiftTimerWidget> createState() => _ShiftTimerWidgetState();
}
class _ShiftTimerWidgetState extends State<ShiftTimerWidget> {
Color _borderColor() =>
widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? AppColors.tintDarkGreen
: AppColors.grayStroke;
Color _labelColor() => widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? AppColors.statusSuccess
: AppColors.blackGray;
Color _counterColor() =>
widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? AppColors.statusSuccess
: AppColors.blackBlack;
Color _bgColor() => widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? AppColors.tintGreen
: AppColors.graySecondaryFrame;
Color _dividerColor() =>
widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? AppColors.tintDarkGreen
: AppColors.grayTintStroke;
Duration _getDuration() {
return widget.shiftStatus == EventShiftRoleStaffStatus.ongoing
? DateTime.now()
.toUtc()
.difference(widget.clockIn ?? DateTime.now().toUtc())
: Duration.zero;
}
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
var duration = _getDuration();
var hours = duration.inHours.remainder(24).abs().toString().padLeft(2, '0');
var minutes =
duration.inMinutes.remainder(60).abs().toString().padLeft(2, '0');
var seconds =
duration.inSeconds.remainder(60).abs().toString().padLeft(2, '0');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 80,
decoration: BoxDecoration(
color: _bgColor(),
border: Border.all(color: _borderColor()),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTimeText('hours'.tr(), hours),
_divider(),
_buildTimeText('minutes'.tr(), minutes),
_divider(),
_buildTimeText('seconds'.tr(), seconds),
],
),
),
],
);
}
Widget _divider() {
return SizedBox(
width: 24,
child: Center(
child: Container(
height: 24,
width: 1,
color: _dividerColor(),
),
),
);
}
Widget _buildTimeText(String label, String time) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(time,
style: AppTextStyles.headingH3.copyWith(color: _counterColor())),
const Gap(6),
Text(
label,
style: AppTextStyles.bodySmallReg.copyWith(color: _labelColor()),
),
],
);
}
}