feat: Refactor code structure and optimize performance across multiple modules

This commit is contained in:
Achintha Isuru
2025-11-17 23:29:28 -05:00
parent 831570f2e0
commit a64cbd9edf
1508 changed files with 105319 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import 'package:bloc/bloc.dart';
import 'package:krow/core/entity/staff_contact_entity.dart';
import 'package:meta/meta.dart';
part 'assigned_staff_event.dart';
part 'assigned_staff_state.dart';
class AssignedStaffBloc extends Bloc<AssignedStaffEvent, AssignedStaffState> {
AssignedStaffBloc(
{required List<StaffContact> staffContacts, required String department})
: super(AssignedStaffState(
staffContacts: staffContacts,
department: department,
)) {}
}

View File

@@ -0,0 +1,4 @@
part of 'assigned_staff_bloc.dart';
@immutable
sealed class AssignedStaffEvent {}

View File

@@ -0,0 +1,23 @@
part of 'assigned_staff_bloc.dart';
@immutable
class AssignedStaffState {
final List<StaffContact> staffContacts;
final String department;
final bool inLoading;
const AssignedStaffState({required this.staffContacts, required this.department, this.inLoading = false});
copyWith({
List<StaffContact>? staffContacts,
String? department,
bool? canManualAssign,
bool? inLoading,
}) {
return AssignedStaffState(
staffContacts: staffContacts ?? this.staffContacts,
department: department ?? this.department,
inLoading: inLoading ?? this.inLoading,
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/entity/staff_contact_entity.dart';
import 'package:krow/core/presentation/widgets/assigned_staff_item_widget.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/features/assigned_staff_screen/domain/assigned_staff_bloc.dart';
@RoutePage()
class AssignedStaffScreen extends StatelessWidget implements AutoRouteWrapper {
final List<StaffContact> staffContacts;
final String department;
const AssignedStaffScreen(
{super.key, required this.staffContacts, required this.department});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => AssignedStaffBloc(
staffContacts: staffContacts, department: department),
child: this);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssignedStaffBloc, AssignedStaffState>(
builder: (context, state) {
return Scaffold(
appBar: KwAppBar(
titleText: 'Assigned Staff',
),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: staffContacts.length,
itemBuilder: (context, index) {
return AssignedStaffItemWidget(
staffContact: staffContacts[index], department: department);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,42 @@
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/features/clock_manual/data/clock_manual_gql.dart';
@singleton
class ClockManualApiProvider {
final ApiClient _client;
ClockManualApiProvider({required ApiClient client}) : _client = client;
Future<void> trackClientClockin(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: trackClientClockinMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception!);
}
}
Future<void> trackClientClockout(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: trackClientClockoutMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception!);
}
}
Future<void> cancelClockin(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: cancelClockinMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception!);
}
}
}

View File

@@ -0,0 +1,23 @@
const String trackClientClockinMutation = r'''
mutation track_client_clockin($position_staff_id: ID!) {
track_client_clockin(position_staff_id: $position_staff_id) {
id
}
}
''';
const String trackClientClockoutMutation = r'''
mutation track_client_clockout($position_staff_id: ID!) {
track_client_clockout(position_staff_id: $position_staff_id) {
id
}
}
''';
const String cancelClockinMutation = r'''
mutation cancel_client_clockin($position_staff_id: ID!) {
cancel_client_clockin(position_staff_id: $position_staff_id) {
id
}
}
''';

View File

@@ -0,0 +1,43 @@
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/features/clock_manual/data/clock_manual_api_provider.dart';
import 'package:krow/features/clock_manual/domain/clock_manual_repository.dart';
@Singleton(as: ClockManualRepository)
class ClockManualRepositoryImpl implements ClockManualRepository {
final ClockManualApiProvider apiProvider;
ClockManualRepositoryImpl({required this.apiProvider});
@override
Future<void> trackClientClockin(String positionStaffId) async {
try {
await apiProvider.trackClientClockin(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> trackClientClockout(String positionStaffId) async {
try {
await apiProvider.trackClientClockout(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> cancelClockin(String positionStaffId) async{
try {
await apiProvider.cancelClockin(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
}

View File

@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:krow/core/application/clients/api/api_exception.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/models/staff/pivot.dart';
import 'package:krow/core/entity/staff_contact_entity.dart';
import 'package:krow/features/clock_manual/domain/clock_manual_repository.dart';
part 'clock_manual_event.dart';
part 'clock_manual_state.dart';
class ClockManualBloc extends Bloc<ClockManualEvent, ClockManualState> {
var staffContacts = <StaffContact>[];
ClockManualBloc({required this.staffContacts})
: super(ClockManualState(
timers: Map.fromEntries(
staffContacts.map((e) => MapEntry(e.id, ValueNotifier<int>(0)))),
ongoingStaffContacts: staffContacts
.where((element) => element.status == PivotStatus.ongoing)
.toList()
..sort((a, b) => a.startAt.compareTo(b.startAt)),
confirmedStaffContacts: staffContacts
.where((element) => element.status == PivotStatus.confirmed)
.toList()
..sort((a, b) => a.startAt.compareTo(b.startAt)),
)) {
on<ChangeSelectedTabIndexClockManual>(_onChangeSelectedTabIndexStaffManual);
on<ClockInManual>(_onClockInStaffManual);
on<SortStaffContactsClockManual>(_sortStaffContactsClockManual);
on<ClockOutManual>(_onClockOutStaff);
on<CancelClockInManual>(_onCancelClockInStaffManual);
}
void _onChangeSelectedTabIndexStaffManual(
ChangeSelectedTabIndexClockManual event, Emitter<ClockManualState> emit) {
emit(state.copyWith(selectedTabIndex: event.index));
}
void _onClockInStaffManual(ClockInManual event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<ClockManualRepository>()
.trackClientClockin(event.staffContact.id);
_startCountdownTimer(
state.timers[event.staffContact.id]!, event.staffContact);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(
errorMessage: e.message,
));
}
}
emit(state.copyWith(inLoading: false));
}
void _onClockOutStaff(ClockOutManual event, emit) async {
emit(state.copyWith(inLoading: false));
try {
await getIt<ClockManualRepository>()
.trackClientClockout(event.staffContact.id);
event.staffContact.status = PivotStatus.completed;
add(SortStaffContactsClockManual());
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(
errorMessage: e.message,
));
}
}
emit(state.copyWith(inLoading: false));
}
void _onCancelClockInStaffManual(CancelClockInManual event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<ClockManualRepository>().cancelClockin(event.staffContact.id);
state.timers[event.staffContact.id]!.value = -1;
event.staffContact.status = PivotStatus.confirmed;
add(SortStaffContactsClockManual());
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(
errorMessage: e.message,
));
}
}
emit(state.copyWith(inLoading: false));
}
void _sortStaffContactsClockManual(SortStaffContactsClockManual event, emit) {
emit(state.copyWith(
ongoingStaffContacts: staffContacts
.where((element) => element.status == PivotStatus.ongoing)
.toList()
..sort((a, b) => a.startAt.compareTo(b.startAt)),
confirmedStaffContacts: staffContacts
.where((element) => element.status == PivotStatus.confirmed)
.toList()
..sort((a, b) => a.startAt.compareTo(b.startAt)),
));
}
void _startCountdownTimer(ValueNotifier<int> cancelTimer, staffContact) {
cancelTimer.value = 5;
Timer.periodic(const Duration(seconds: 1), (timer) {
if (cancelTimer.value > 0) {
cancelTimer.value -= 1;
} else if (cancelTimer.value == 0) {
staffContact.status = PivotStatus.ongoing;
add(SortStaffContactsClockManual());
timer.cancel();
} else {
timer.cancel();
}
});
}
}

View File

@@ -0,0 +1,30 @@
part of 'clock_manual_bloc.dart';
@immutable
sealed class ClockManualEvent {}
class ChangeSelectedTabIndexClockManual extends ClockManualEvent {
final int index;
ChangeSelectedTabIndexClockManual(this.index);
}
class ClockInManual extends ClockManualEvent {
final StaffContact staffContact;
ClockInManual(this.staffContact);
}
class SortStaffContactsClockManual extends ClockManualEvent {}
class ClockOutManual extends ClockManualEvent {
final StaffContact staffContact;
ClockOutManual(this.staffContact);
}
class CancelClockInManual extends ClockManualEvent {
final StaffContact staffContact;
CancelClockInManual(this.staffContact);
}

View File

@@ -0,0 +1,39 @@
part of 'clock_manual_bloc.dart';
@immutable
class ClockManualState {
final List<StaffContact> confirmedStaffContacts;
final List<StaffContact> ongoingStaffContacts;
final int selectedTabIndex;
final bool inLoading;
final String? errorMessage;
final Map<String, ValueNotifier<int>> timers ;
const ClockManualState(
{required this.confirmedStaffContacts,
required this.ongoingStaffContacts,
this.inLoading = false,
this.errorMessage,
this.timers = const {},
this.selectedTabIndex = 0});
copyWith({
List<StaffContact>? confirmedStaffContacts,
List<StaffContact>? ongoingStaffContacts,
bool? inLoading,
String? errorMessage,
int? selectedTabIndex,
Map<String, ValueNotifier<int>>? timers,
}) {
return ClockManualState(
timers: timers ?? this.timers,
confirmedStaffContacts:
confirmedStaffContacts ?? this.confirmedStaffContacts,
ongoingStaffContacts: ongoingStaffContacts ?? this.ongoingStaffContacts,
inLoading: inLoading ?? this.inLoading,
errorMessage: errorMessage ?? this.errorMessage,
selectedTabIndex: selectedTabIndex ?? this.selectedTabIndex,
);
}
}

View File

@@ -0,0 +1,7 @@
abstract class ClockManualRepository {
Future<void> trackClientClockin(String positionStaffId);
Future<void> trackClientClockout(String positionStaffId);
Future<void> cancelClockin(String positionStaffId);
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/foundation.dart';
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/str_extensions.dart';
import 'package:krow/core/data/models/staff/pivot.dart';
import 'package:krow/core/entity/staff_contact_entity.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/clock_manual/domain/bloc/clock_manual_bloc.dart';
class AssignedStaffManualItemWidget extends StatelessWidget {
final StaffContact staffContact;
final ValueNotifier<int> timer;
const AssignedStaffManualItemWidget(
{super.key, required this.staffContact, required this.timer});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: timer,
builder: (BuildContext context, int value, Widget? child) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: KwBoxDecorations.white8,
child: Column(
children: [
_staffInfo(),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(staffContact.status == PivotStatus.ongoing?'End Time': 'Start Time',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
Text(
DateFormat('hh:mm a').format(
(staffContact.status == PivotStatus.ongoing
? staffContact.parentPosition?.endTime
: staffContact.parentPosition?.startTime) ??
DateTime.now()),
style: AppTextStyles.bodySmallMed,
),
],
),
const Gap(12),
if (staffContact.status == PivotStatus.confirmed && value <= 0)
_buildClockIn(context),
if (staffContact.status == PivotStatus.ongoing)
_buildClockOut(context),
if (value > 0) _buildCancel(context),
if (kDebugMode) _buildCancel(context),
],
),
);
},
);
}
KwButton _buildCancel(BuildContext context) {
return KwButton.outlinedPrimary(
onPressed: () {
BlocProvider.of<ClockManualBloc>(context)
.add(CancelClockInManual(staffContact));
},
label: 'Click again to cancel (${timer.value}) ',
height: 36)
.copyWith(
textColors: AppColors.blackGray,
color: AppColors.grayWhite,
isFilledOutlined: true,
borderColor: AppColors.grayTintStroke,
pressedColor: AppColors.grayWhite);
}
KwButton _buildClockIn(BuildContext context) {
return KwButton.outlinedPrimary(
onPressed: () {
BlocProvider.of<ClockManualBloc>(context)
.add(ClockInManual(staffContact));
},
label: 'Clock In',
height: 36)
.copyWith(
textColors: AppColors.statusSuccess,
color: AppColors.tintGreen,
isFilledOutlined: true,
borderColor: AppColors.tintDarkGreen,
pressedColor: AppColors.tintDarkGreen);
}
KwButton _buildClockOut(BuildContext context) {
return KwButton.outlinedPrimary(
onPressed: () {
BlocProvider.of<ClockManualBloc>(context)
.add(ClockOutManual(staffContact));
},
label: 'Clock Out',
height: 36)
.copyWith(
textColors: AppColors.statusError,
color: AppColors.tintRed,
isFilledOutlined: true,
borderColor: AppColors.tintDarkRed,
pressedColor: AppColors.tintDarkRed);
}
Row _staffInfo() {
return Row(
children: [
if ((staffContact.photoUrl ?? '').isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: NetworkImage(staffContact.photoUrl ?? ''),
),
const Gap(12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${staffContact.firstName} ${staffContact.lastName}',
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodyMediumMed,
),
const Gap(4),
Text(
staffContact.phoneNumber ?? '',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
],
),
),
const Gap(8),
SizedBox(
height: 40,
child: Align(
alignment: Alignment.topCenter,
child: Container(
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
border: Border.all(
color: staffContact.status.getStatusBorderColor(),
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 6,
height: 6,
padding: const EdgeInsets.all(0),
decoration: BoxDecoration(
color: staffContact.status.getStatusTextColor(),
borderRadius: BorderRadius.circular(3),
),
),
const Gap(2),
Text(
staffContact.status.formattedName.capitalize(),
style: AppTextStyles.bodyTinyMed.copyWith(
color: staffContact.status.getStatusTextColor()),
)
],
),
),
),
)
],
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/entity/staff_contact_entity.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_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_option_selector.dart';
import 'package:krow/features/clock_manual/domain/bloc/clock_manual_bloc.dart';
import 'package:krow/features/clock_manual/presentation/clock_manual_list_item.dart';
@RoutePage()
class ClockManualScreen extends StatelessWidget implements AutoRouteWrapper {
final List<StaffContact> staffContacts;
const ClockManualScreen({
super.key,
required this.staffContacts,
});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => ClockManualBloc(staffContacts: staffContacts),
child: this);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ClockManualBloc, ClockManualState>(
listenWhen: (previous, current) =>
previous.errorMessage != current.errorMessage,
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.errorMessage ?? ''),
));
}
},
builder: (context, state) {
var items = state.selectedTabIndex == 0
? state.confirmedStaffContacts
: state.ongoingStaffContacts;
return Scaffold(
appBar: KwAppBar(
titleText: 'Assigned Staff',
),
body: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16),
child: KwOptionSelector(
selectedIndex: state.selectedTabIndex,
onChanged: (index) {
BlocProvider.of<ClockManualBloc>(context)
.add(ChangeSelectedTabIndexClockManual(index));
},
height: 26,
selectorHeight: 4,
textStyle: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
selectedTextStyle: AppTextStyles.bodyMediumMed,
itemAlign: Alignment.topCenter,
items: const [
'Clock In',
'Clock Out',
]),
),
ListView.builder(
primary: false,
shrinkWrap: true,
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (context, index) {
return AssignedStaffManualItemWidget(
staffContact: items[index],
timer: state.timers[items[index].id]!,
);
},
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
@RoutePage()
class CreateEventFlowScreen extends StatelessWidget {
const CreateEventFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return const AutoRouter();
}
}

View File

@@ -0,0 +1,119 @@
import 'package:graphql/client.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/event/addon_model.dart';
import 'package:krow/core/data/models/event/business_member_model.dart';
import 'package:krow/core/data/models/event/hub_model.dart';
import 'package:krow/core/data/models/event/tag_model.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.dart';
import 'package:krow/features/create_event/data/create_event_gql.dart';
@Injectable()
class CreateEventApiProvider {
final ApiClient _client;
CreateEventApiProvider({required ApiClient client}) : _client = client;
Future<List<HubModel>> getHubs() async {
QueryResult result = await _client.query(
schema: getClientHubsQuery,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_hubs'] == null) {
return [];
}
return (result.data!['client_hubs'] as List)
.map((e) => HubModel.fromJson(e))
.toList();
}
Future<List<AddonModel>> getAddons() async {
QueryResult result = await _client.query(
schema: getClientAddonsQuery,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_business_addons'] == null) {
return [];
}
return (result.data!['client_business_addons'] as List)
.map((e) => AddonModel.fromJson(e))
.toList();
}
Future<List<TagModel>> getTags() async {
QueryResult result = await _client.query(
schema: getClientTagsQuery,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_tags'] == null) {
return [];
}
return (result.data!['client_tags'] as List)
.map((e) => TagModel.fromJson(e))
.toList();
}
Future<List<BusinessMemberModel>> getContacts() async {
QueryResult result = await _client.query(
schema: getClientMembersQuery,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_shift_contacts'] == null) {
return [];
}
return (result.data!['client_shift_contacts'] as List)
.map((e) => BusinessMemberModel.fromJson(e))
.toList();
}
Future<List<BusinessSkillModel>> getSkill() async {
QueryResult result = await _client.query(
schema: getClientBusinessSkillQuery,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_business_skills'] == null) {
return [];
}
return (result.data!['client_business_skills'] as List)
.map((e) => BusinessSkillModel.fromJson(e))
.toList();
}
Future<List<DepartmentModel>> getDepartments(String hubId) async {
QueryResult result = await _client.query(
schema: getClientDepartmentsQuery,
body: {'hub_id': hubId},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!['client_hub_departments'] == null) {
return [];
}
return (result.data!['client_hub_departments'] as List)
.map((e) => DepartmentModel.fromJson(e))
.toList();
}
}

View File

@@ -0,0 +1,82 @@
const String getClientHubsQuery = r'''
query getClientHubs() {
client_hubs {
id
name
address
full_address {
street_number
zip_code
latitude
longitude
formatted_address
street
region
city
country
}
}
}
''';
const String getClientAddonsQuery = r'''
query getClientAddons() {
client_business_addons {
id
name
}
}
''';
const String getClientTagsQuery = r'''
query getClientTags() {
client_tags {
id
name
slug
}
}
''';
const String getClientBusinessSkillQuery = r'''
query getClientSkills() {
client_business_skills {
id
skill {
id
name
slug
price
}
price
is_active
}
}
''';
const String getClientMembersQuery = r'''
query getClientMembers() {
client_shift_contacts() {
id
first_name
last_name
title
avatar
auth_info {
email
phone
}
}
}
''';
const String getClientDepartmentsQuery = r'''
query getClientSkills($hub_id: ID!) {
client_hub_departments(hub_id: $hub_id) {
id
name
}
}
''';

View File

@@ -0,0 +1,48 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/models/event/addon_model.dart';
import 'package:krow/core/data/models/event/business_member_model.dart';
import 'package:krow/core/data/models/event/hub_model.dart';
import 'package:krow/core/data/models/event/tag_model.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.dart';
import 'package:krow/features/create_event/data/create_event_api_provider.dart';
import 'package:krow/features/create_event/domain/create_event_repository.dart';
@Singleton(as: CreateEventRepository)
class CreateEventRepositoryImpl extends CreateEventRepository {
final CreateEventApiProvider _apiProvider;
CreateEventRepositoryImpl({required CreateEventApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Future<List<HubModel>> getHubs() async {
return _apiProvider.getHubs();
}
@override
Future<List<AddonModel>> getAddons() async {
return _apiProvider.getAddons();
}
@override
Future<List<TagModel>> getTags() async {
return _apiProvider.getTags();
}
@override
Future<List<BusinessMemberModel>> getContacts() async {
return _apiProvider.getContacts();
}
@override
Future<List<BusinessSkillModel>> getSkills() {
return _apiProvider.getSkill();
}
@override
Future<List<DepartmentModel>> getDepartments(String hubId) {
return _apiProvider.getDepartments(hubId);
}
}

View File

@@ -0,0 +1,230 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/models/event/addon_model.dart';
import 'package:krow/core/data/models/event/business_member_model.dart';
import 'package:krow/core/data/models/event/event_model.dart';
import 'package:krow/core/data/models/event/hub_model.dart';
import 'package:krow/core/data/models/event/tag_model.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:krow/core/entity/shift_entity.dart';
import 'package:krow/core/sevices/create_event_service/create_event_service.dart';
import 'package:krow/features/create_event/domain/create_event_repository.dart';
import 'package:krow/features/create_event/domain/input_validator.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
part 'create_event_event.dart';
part 'create_event_state.dart';
class CreateEventBloc extends Bloc<CreateEventEvent, CreateEventState> {
CreateEventBloc() : super(CreateEventState(entity: EventEntity.empty())) {
on<CreateEventInit>(_onInit);
// on<CreateEventChangeContractType>(_onChangeContractType);
on<CreateEventChangeHub>(_onChangeHub);
// on<CreateEventChangeContractNumber>(_onChangeContractNumber);
on<CreateEventChangePoNumber>(_onChangePoNumber);
on<CreateEventTagSelected>(_onTagSelected);
on<CreateEventAddShift>(_onAddShift);
on<CreateEventRemoveShift>(_onRemoveShift);
on<CreateEventAddInfoChange>(_onAddInfoChange);
on<CreateEventNameChange>(_onNameChange);
on<CreateEventToggleAddon>(_onToggleAddon);
on<CreateEventEntityUpdatedEvent>(_onEntityUpdated);
on<CreateEventValidateAndPreview>(_onValidateAndPreview);
on<DeleteDraftEvent>(_onDeleteDraft);
}
Future<void> _onInit(
CreateEventInit event, Emitter<CreateEventState> emit) async {
emit(state.copyWith(inLoading: true));
late EventEntity entity;
bool? isEdit = event.eventModel != null;
if (isEdit) {
entity = EventEntity.fromEventDto(event.eventModel!);
} else {
entity = EventEntity.empty();
}
List<ShiftViewModel> shiftViewModels = [
...entity.shifts?.map((shiftEntity) {
return ShiftViewModel(
id: shiftEntity.id,
bloc: CreateShiftDetailsBloc(expanded: !isEdit)
..add(CreateShiftInitializeEvent(shiftEntity)),
);
}).toList() ??
[],
];
emit(state.copyWith(
recurringType: event.recurringType,
entity: entity,
tags: [
//placeholder for tags - prevent UI jumping
TagModel(id: '1', name: ' '),
TagModel(id: '2', name: ' '),
TagModel(id: '3', name: ' ')
],
shifts: shiftViewModels));
List<Object> results;
try {
results = await Future.wait([
getIt<CreateEventRepository>().getHubs().onError((e, s) {
return [];
}),
getIt<CreateEventRepository>().getAddons().onError((e, s) => []),
getIt<CreateEventRepository>().getTags().onError((e, s) => []),
getIt<CreateEventRepository>().getContacts().onError((e, s) => []),
getIt<CreateEventRepository>().getSkills().onError((e, s) => []),
]);
} catch (e) {
emit(state.copyWith(inLoading: false));
return;
}
var hubs = results[0] as List<HubModel>;
var addons = results[1] as List<AddonModel>;
var tags = results[2] as List<TagModel>;
var contacts = results[3] as List<BusinessMemberModel>;
var skills = results[4] as List<BusinessSkillModel>;
emit(state.copyWith(
inLoading: false,
hubs: hubs,
tags: tags,
addons: addons,
contacts: contacts,
skills: skills));
}
void _onChangeHub(
CreateEventChangeHub event, Emitter<CreateEventState> emit) async {
if (event.hub == state.entity.hub) return;
emit(state.copyWith(
entity: state.entity.copyWith(hub: event.hub),
));
state.shifts.forEach((element) {
element.bloc
.add(CreateShiftAddressSelectEvent(address: event.hub.fullAddress));
});
var departments =
await getIt<CreateEventRepository>().getDepartments(event.hub.id);
emit(state.copyWith(
departments: departments,
));
}
// void _onChangeContractType(
// CreateEventChangeContractType event, Emitter<CreateEventState> emit) {
// if (event.contractType == state.entity.contractType) return;
// emit(state.copyWith(
// entity: state.entity.copyWith(contractType: event.contractType),
// ));
// }
// void _onChangeContractNumber(
// CreateEventChangeContractNumber event, Emitter<CreateEventState> emit) {
// emit(state.copyWith(
// entity: state.entity.copyWith(contractNumber: event.contractNumber),
// ));
// }
//
void _onChangePoNumber(
CreateEventChangePoNumber event, Emitter<CreateEventState> emit) {
emit(state.copyWith(
entity: state.entity.copyWith(poNumber: event.poNumber),
));
}
void _onTagSelected(
CreateEventTagSelected event, Emitter<CreateEventState> emit) {
final tags = List<TagModel>.of(state.entity.tags ?? []);
if (tags.any((e) => e.id == event.tag.id)) {
tags.removeWhere((e) => e.id == event.tag.id);
} else {
tags.add(event.tag);
}
emit(state.copyWith(
entity: state.entity.copyWith(tags: tags),
));
}
void _onAddShift(CreateEventAddShift event, Emitter<CreateEventState> emit) {
final id = DateTime.now().millisecondsSinceEpoch.toString();
ShiftEntity newShiftEntity = ShiftEntity.empty();
final bloc = CreateShiftDetailsBloc(expanded: true)
..add(CreateShiftInitializeEvent(
newShiftEntity,
));
newShiftEntity.parentEvent = state.entity;
state.entity.shifts?.add(newShiftEntity);
emit(state.copyWith(
shifts: [
...state.shifts,
ShiftViewModel(id: id, bloc: bloc),
],
));
}
void _onRemoveShift(
CreateEventRemoveShift event, Emitter<CreateEventState> emit) {
emit(state.copyWith(
entity: state.entity.copyWith(
shifts: state.entity.shifts
?.where((element) => element.id != event.id)
.toList()),
shifts: state.shifts.where((element) => element.id != event.id).toList(),
));
}
void _onNameChange(
CreateEventNameChange event, Emitter<CreateEventState> emit) {
emit(state.copyWith(
entity: state.entity.copyWith(name: event.value),
));
}
void _onAddInfoChange(
CreateEventAddInfoChange event, Emitter<CreateEventState> emit) {
emit(state.copyWith(
entity: state.entity.copyWith(additionalInfo: event.value),
));
}
void _onToggleAddon(
CreateEventToggleAddon event, Emitter<CreateEventState> emit) {
final addons = List<AddonModel>.of(state.entity.addons ?? []);
if (addons.any((e) => e.id == event.addon.id)) {
addons.removeWhere((e) => e.id == event.addon.id);
} else {
addons.add(event.addon);
}
emit(state.copyWith(
entity: state.entity.copyWith(addons: addons),
));
}
void _onEntityUpdated(
CreateEventEntityUpdatedEvent event, Emitter<CreateEventState> emit) {
emit(state.copyWith());
}
void _onValidateAndPreview(
CreateEventValidateAndPreview event, Emitter<CreateEventState> emit) {
var newState = CreateEventInputValidator.validateInputs(
state.copyWith(entity: state.entity.copyWith()));
emit(newState);
emit(newState.copyWith(valid: false));
}
void _onDeleteDraft(
DeleteDraftEvent event, Emitter<CreateEventState> emit) async {
await getIt<CreateEventService>().deleteDraft(state.entity);
}
}

View File

@@ -0,0 +1,81 @@
part of 'create_event_bloc.dart';
@immutable
sealed class CreateEventEvent {}
class CreateEventInit extends CreateEventEvent {
final EventModel? eventModel;
final EventScheduleType? recurringType;
CreateEventInit(this.recurringType, this.eventModel);
}
// class CreateEventChangeContractType extends CreateEventEvent {
// final EventContractType contractType;
//
// CreateEventChangeContractType(this.contractType);
// }
class CreateEventChangeHub extends CreateEventEvent {
final HubModel hub;
CreateEventChangeHub(this.hub);
}
class CreateEventValidateAndPreview extends CreateEventEvent {
CreateEventValidateAndPreview();
}
// class CreateEventChangeContractNumber extends CreateEventEvent {
// final String contractNumber;
//
// CreateEventChangeContractNumber(this.contractNumber);
// }
class CreateEventChangePoNumber extends CreateEventEvent {
final String poNumber;
CreateEventChangePoNumber(this.poNumber);
}
class CreateEventNameChange extends CreateEventEvent {
final String value;
CreateEventNameChange(this.value);
}
class CreateEventAddInfoChange extends CreateEventEvent {
final String value;
CreateEventAddInfoChange(this.value);
}
class CreateEventTagSelected extends CreateEventEvent {
final TagModel tag;
CreateEventTagSelected(this.tag);
}
class CreateEventAddShift extends CreateEventEvent {
CreateEventAddShift();
}
class CreateEventRemoveShift extends CreateEventEvent {
final String id;
CreateEventRemoveShift(this.id);
}
class CreateEventToggleAddon extends CreateEventEvent {
final AddonModel addon;
CreateEventToggleAddon(this.addon);
}
class CreateEventEntityUpdatedEvent extends CreateEventEvent {
CreateEventEntityUpdatedEvent();
}
class DeleteDraftEvent extends CreateEventEvent {
DeleteDraftEvent();
}

View File

@@ -0,0 +1,138 @@
part of 'create_event_bloc.dart';
@immutable
class CreateEventState {
final bool inLoading;
final bool success;
final bool valid;
final EventEntity entity;
final EventScheduleType recurringType;
final List<HubModel> hubs;
final List<TagModel> tags;
final List<BusinessMemberModel> contacts;
final List<ShiftViewModel> shifts;
final List<AddonModel> addons;
final List<BusinessSkillModel> skills;
final List<DepartmentModel> departments;
final EventValidationState? validationState;
final bool showEmptyFieldError;
const CreateEventState(
{required this.entity,
this.inLoading = false,
this.valid = false,
this.showEmptyFieldError = false,
this.success = false,
this.recurringType = EventScheduleType.oneTime,
this.hubs = const [],
this.tags = const [],
this.contacts = const [],
this.addons = const [],
this.skills = const [],
this.shifts = const [],
this.departments = const [],
this.validationState});
CreateEventState copyWith({
bool? inLoading,
bool? success,
bool? valid,
bool? showEmptyFieldError,
EventEntity? entity,
EventScheduleType? recurringType,
List<HubModel>? hubs,
List<TagModel>? tags,
List<BusinessMemberModel>? contacts,
List<ShiftViewModel>? shifts,
List<AddonModel>? addons,
List<BusinessSkillModel>? skills,
List<DepartmentModel>? departments,
EventValidationState? validationState,
}) {
return CreateEventState(
success: success ?? this.success,
valid: valid ?? this.valid,
inLoading: inLoading ?? this.inLoading,
showEmptyFieldError: showEmptyFieldError ?? this.showEmptyFieldError,
entity: entity ?? this.entity,
recurringType: recurringType ?? this.recurringType,
hubs: hubs ?? this.hubs,
tags: tags ?? this.tags,
contacts: contacts ?? this.contacts,
shifts: shifts ?? this.shifts,
addons: addons ?? this.addons,
skills: skills ?? this.skills,
departments: departments ?? this.departments,
validationState: validationState,
);
}
}
class ShiftViewModel {
final String id;
final CreateShiftDetailsBloc bloc;
ShiftViewModel({required this.id, required this.bloc});
}
class EventValidationState {
final String? nameError;
final String? startDateError;
final String? endDateError;
final String? hubError;
// final String? contractNumberError;
final String? poNumberError;
final String? shiftsError;
bool showed = false;
bool get hasError =>
nameError != null ||
startDateError != null ||
endDateError != null ||
hubError != null ||
// contractNumberError != null ||
poNumberError != null ||
shiftsError != null;
String? get message {
return nameError ??
startDateError ??
endDateError ??
hubError ??
// contractNumberError ??
poNumberError ??
shiftsError ??
'';
}
EventValidationState(
{this.nameError,
this.startDateError,
this.endDateError,
this.hubError,
// this.contractNumberError,
this.poNumberError,
this.shiftsError});
EventValidationState copyWith({
String? nameError,
String? startDateError,
String? endDateError,
String? hubError,
// String? contractNumberError,
String? poNumberError,
String? shiftsError,
}) {
return EventValidationState(
nameError: nameError ?? this.nameError,
startDateError: startDateError ?? this.startDateError,
endDateError: endDateError ?? this.endDateError,
hubError: hubError ?? this.hubError,
// contractNumberError: contractNumberError ?? this.contractNumberError,
poNumberError: poNumberError ?? this.poNumberError,
shiftsError: shiftsError ?? this.shiftsError,
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:krow/core/data/models/event/addon_model.dart';
import 'package:krow/core/data/models/event/business_member_model.dart';
import 'package:krow/core/data/models/event/hub_model.dart';
import 'package:krow/core/data/models/event/tag_model.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.dart';
abstract class CreateEventRepository {
Future<List<HubModel>> getHubs();
Future<List<AddonModel>> getAddons();
Future<List<TagModel>> getTags();
Future<List<BusinessMemberModel>> getContacts();
Future<List<BusinessSkillModel>> getSkills();
Future<List<DepartmentModel>> getDepartments(String hubId);
}

View File

@@ -0,0 +1,96 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart';
@singleton
class GooglePlacesService {
Future<List<MapPlace>> fetchSuggestions(String query) async {
final String apiKey = dotenv.env['GOOGLE_MAP']!;
const String baseUrl =
'https://maps.googleapis.com/maps/api/place/autocomplete/json';
final Uri uri =
Uri.parse('$baseUrl?input=$query&key=$apiKey&types=geocode');
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final List<dynamic> predictions = data['predictions'];
return predictions.map((prediction) {
return MapPlace.fromJson(prediction);
}).toList();
} else {
throw Exception('Failed to fetch place suggestions');
}
}
Future<Map<String, dynamic>> getPlaceDetails(String placeId) async {
final String apiKey = dotenv.env['GOOGLE_MAP']!;
final String url =
'https://maps.googleapis.com/maps/api/place/details/json?place_id=$placeId&key=$apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final result = data['result'];
final location = result['geometry']['location'];
Map<String, dynamic> addressDetails = {
'lat': location['lat'], // Latitude
'lng': location['lng'], // Longitude
'formatted_address': result['formatted_address'], // Full Address
};
for (var component in result['address_components']) {
List types = component['types'];
if (types.contains('street_number')) {
addressDetails['street_number'] = component['long_name'];
}
if (types.contains('route')) {
addressDetails['street'] = component['long_name'];
}
if (types.contains('locality')) {
addressDetails['city'] = component['long_name'];
}
if (types.contains('administrative_area_level_1')) {
addressDetails['state'] = component['long_name'];
}
if (types.contains('country')) {
addressDetails['country'] = component['long_name'];
}
if (types.contains('postal_code')) {
addressDetails['postal_code'] = component['long_name'];
}
}
return addressDetails;
} else {
throw Exception('Failed to fetch place details');
}
}
}
class MapPlace {
final String description;
final String placeId;
MapPlace({required this.description, required this.placeId});
toJson() {
return {
'description': description,
'place_id': placeId,
};
}
factory MapPlace.fromJson(Map<String, dynamic> json) {
return MapPlace(
description: json['description'],
placeId: json['place_id'],
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:krow/core/data/models/event/event_model.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/create_role_section/bloc/create_role_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
class CreateEventInputValidator {
static CreateEventState validateInputs(CreateEventState state) {
var newState = _validateEvent(state);
newState = _validateShifts(newState);
return newState;
}
static CreateEventState _validateEvent(CreateEventState state) {
EventValidationState? validationState;
if (state.entity.name.isEmpty) {
validationState = EventValidationState(nameError: 'Name cannot be empty');
}
if (state.entity.startDate == null) {
validationState =
EventValidationState(startDateError: 'Start Date cannot be empty');
}
if (state.recurringType == EventScheduleType.recurring &&
state.entity.endDate == null) {
validationState =
EventValidationState(endDateError: 'End Date cannot be empty');
}
if (state.entity.hub == null) {
validationState = EventValidationState(hubError: 'Hub cannot be empty');
}
// if (state.entity.contractType == EventContractType.contract &&
// (state.entity.contractNumber?.isEmpty ?? true)) {
// validationState = EventValidationState(
// contractNumberError: 'Contract Number cannot be empty');
// }
if ((state.entity.poNumber?.isEmpty ?? true)) {
validationState =
EventValidationState(poNumberError: 'PO Number cannot be empty');
}
if (state.shifts.isEmpty) {
validationState =
EventValidationState(shiftsError: 'Shifts cannot be empty');
}
if (state.validationState == null && validationState == null) {
return state.copyWith(valid: true);
} else {
return state.copyWith(validationState: validationState);
}
}
static CreateEventState _validateShifts(CreateEventState state) {
for (var shift in state.shifts) {
ShiftValidationState? validationState;
if (!(shift.bloc.state.shift.fullAddress?.isValid() ?? false)) {
validationState = ShiftValidationState(addressError: 'Invalid Address');
}
if (shift.bloc.state.shift.managers.isEmpty) {
validationState =
ShiftValidationState(contactsError: 'Managers cannot be empty');
}
if (validationState != null) {
shift.bloc.add(ValidationFailedEvent(validationState));
return state.copyWith(
valid: false, validationState: state.validationState);
}
if (validatePosition(shift.bloc.state.roles)) {
return state.copyWith(
valid: false, validationState: state.validationState);
}
}
return state;
}
static bool validatePosition(List<RoleViewModel> roles) {
for (var position in roles) {
PositionValidationState? validationState;
if (position.bloc.state.entity.businessSkill == null) {
validationState =
PositionValidationState(skillError: 'Skill cannot be empty');
}
if (position.bloc.state.entity.department == null) {
validationState = PositionValidationState(
departmentError: 'Department cannot be empty');
}
if (position.bloc.state.entity.count == null) {
validationState =
PositionValidationState(countError: 'Count cannot be empty');
}
if (validationState != null) {
position.bloc.add(ValidationPositionFailedEvent(validationState));
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,187 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.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/data/models/event/event_model.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_button.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/create_shifts_list.dart';
import 'package:krow/features/create_event/presentation/event_date_section/bloc/date_selector_bloc.dart';
import 'package:krow/features/create_event/presentation/widgets/add_info_input_widget.dart';
import 'package:krow/features/create_event/presentation/widgets/addons_section_widget.dart';
import 'package:krow/features/create_event/presentation/widgets/create_event_details_card_widget.dart';
import 'package:krow/features/create_event/presentation/widgets/create_event_tags_card.dart';
import 'package:krow/features/create_event/presentation/widgets/create_event_title_widget.dart';
import 'package:krow/features/create_event/presentation/widgets/total_cost_row_widget.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
@RoutePage()
class CreateEventScreen extends StatelessWidget implements AutoRouteWrapper {
final EventScheduleType? eventType;
final EventModel? eventModel;
const CreateEventScreen({super.key, this.eventType, this.eventModel});
@override
Widget build(BuildContext context) {
return BlocConsumer<CreateEventBloc, CreateEventState>(
listener: (context, state) {
if (state.success) {
context.router.maybePop();
}
if (state.valid) {
context.router
.push(EventDetailsRoute(event: state.entity, isPreview: true));
}
_checkValidation(state, context);
},
builder: (context, state) {
return ModalProgressHUD(
inAsyncCall: state.inLoading,
child: Scaffold(
appBar: KwAppBar(
titleText: 'Create Event',
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: CustomScrollView(
primary: false,
slivers: [
SliverList.list(
children: const [
CreateEventTitleWidget(),
CreateEventDetailsCardWidget(),
CreateEventTagsCard(),
],
),
const CreateShiftsList(),
const SliverGap(24),
sliverWrap(
KwButton.outlinedPrimary(
onPressed: () {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventAddShift());
},
label: 'Add Shift',
leftIcon: Assets.images.icons.add,
),
),
const SliverGap(24),
sliverWrap(const AddonsSectionWidget()),
const SliverGap(24),
sliverWrap(const AddInfoInputWidget()),
const SliverGap(24),
sliverWrap( TotalCostRowWidget()),
const SliverGap(24),
SliverSafeArea(
top: false,
sliver: SliverPadding(
padding: const EdgeInsets.only(bottom: 36, top: 36),
sliver: sliverWrap(
KwPopUpButton(
disabled: state.validationState != null,
label: state.entity.id.isEmpty
? 'Save as Draft'
: state.entity.status == EventStatus.draft
? 'Update Draft'
: 'Update Event',
popUpPadding: 16,
items: [
KwPopUpButtonItem(
title: 'Preview',
onTap: () {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventValidateAndPreview());
}),
if (state.entity.status == EventStatus.draft)
KwPopUpButtonItem(
title: 'Delete Event Draft',
onTap: () {
BlocProvider.of<CreateEventBloc>(context)
.add(DeleteDraftEvent());
context.router.popUntilRoot();
context.router.maybePop();
},
color: AppColors.statusError),
],
),
),
),
),
],
),
),
),
);
},
);
}
sliverWrap(Widget widget) {
return SliverToBoxAdapter(
child: widget,
);
}
void _checkValidation(CreateEventState state, BuildContext context) {
if (state.validationState != null && !state.validationState!.showed) {
state.validationState?.showed = true;
for (var e in state.shifts) {
e.bloc.state.validationState?.showed = true;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.validationState?.message ?? ''),
backgroundColor: AppColors.statusError,
));
} else {
var invalidShift = state.shifts
.firstWhereOrNull((e) => e.bloc.state.validationState != null);
if (invalidShift != null &&
!invalidShift.bloc.state.validationState!.showed) {
invalidShift.bloc.state.validationState?.showed = true;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(invalidShift.bloc.state.validationState?.message ?? ''),
backgroundColor: AppColors.statusError,
));
return;
}
var invalidRole = state.shifts
.expand((shift) => shift.bloc.state.roles)
.firstWhereOrNull((role) => role.bloc.state.validationState != null);
if(invalidRole != null && !invalidRole.bloc.state.validationState!.showed) {
invalidRole.bloc.state.validationState?.showed = true;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(invalidRole.bloc.state.validationState?.message ?? ''),
backgroundColor: AppColors.statusError,
));
}
}
}
@override
Widget wrappedRoute(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) =>
CreateEventBloc()..add(CreateEventInit(eventType, eventModel))),
BlocProvider(
create: (_) => DateSelectorBloc()
..add(DateSelectorEventInit(eventType, eventModel))),
],
child: this,
);
}
}

View File

@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/data/models/event/skill.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.dart';
import 'package:krow/core/entity/position_entity.dart';
import 'package:krow/core/entity/role_schedule_entity.dart';
part 'create_role_event.dart';
part 'create_role_state.dart';
class CreateRoleBloc extends Bloc<CreateRoleEvent, CreateRoleState> {
CreateRoleBloc()
: super(CreateRoleState(
isExpanded: true,
entity: PositionEntity.empty(),
)) {
on<CreateRoleInitEvent>(_onRoleInit);
on<CreateRoleSelectSkillEvent>(_onRoleSelect);
on<ExpandRoleEvent>(_onExpandRole);
on<SetRoleStartTimeEvent>(_onSetRoleStartTime);
on<SetRoleEndTimeEvent>(_onSetRoleEndTime);
on<CreateRoleSelectDepartmentEvent>(_onSelectDepartment);
on<CreateRoleSelectCountEvent>(_onSelectCount);
on<CreateRoleSetScheduleEvent>(_onSetRoleSchedule);
on<CreateRoleSelectBreak>(_onSetRoleBreak);
on<ValidationPositionFailedEvent>(_onValidationFailed);
}
FutureOr<void> _onRoleInit(CreateRoleInitEvent event, emit) {
emit(state.copyWith(entity: event.role));
}
FutureOr<void> _onRoleSelect(CreateRoleSelectSkillEvent event, emit) {
emit(state.copyWith(entity: state.entity.copyWith(businessSkill: event.skill)));
}
FutureOr<void> _onExpandRole(event, emit) {
emit(state.copyWith(isExpanded: !state.isExpanded));
}
FutureOr<void> _onSetRoleStartTime(event, emit) {
emit(state.copyWith(
entity: state.entity.copyWith(startTime: event.startTime)));
}
FutureOr<void> _onSetRoleEndTime(event, emit) {
emit(state.copyWith(entity: state.entity.copyWith(endTime: event.endTime)));
}
FutureOr<void> _onSelectDepartment(
CreateRoleSelectDepartmentEvent event, emit) {
emit(state.copyWith(entity: state.entity.copyWith(department: event.item)));
}
FutureOr<void> _onSelectCount(CreateRoleSelectCountEvent event, emit) {
emit(state.copyWith(entity: state.entity.copyWith(count: event.item)));
}
FutureOr<void> _onSetRoleSchedule(CreateRoleSetScheduleEvent event, emit) {
emit(state.copyWith(
entity: state.entity.copyWith(schedule: event.schedule)));
}
FutureOr<void> _onSetRoleBreak(CreateRoleSelectBreak event, emit) {
emit(state.copyWith(entity: state.entity.copyWith(breakDuration: event.item)));
}
FutureOr<void> _onValidationFailed(ValidationPositionFailedEvent event, emit) {
emit(state.copyWith(validationState: event.validationState));
}
}

View File

@@ -0,0 +1,62 @@
part of 'create_role_bloc.dart';
@immutable
sealed class CreateRoleEvent {}
class CreateRoleInitEvent extends CreateRoleEvent {
final PositionEntity role;
CreateRoleInitEvent(this.role);
}
class CreateRoleSelectSkillEvent extends CreateRoleEvent {
final BusinessSkillModel skill;
CreateRoleSelectSkillEvent(this.skill);
}
class ExpandRoleEvent extends CreateRoleEvent {
ExpandRoleEvent();
}
class SetRoleStartTimeEvent extends CreateRoleEvent {
final DateTime startTime;
SetRoleStartTimeEvent(this.startTime);
}
class SetRoleEndTimeEvent extends CreateRoleEvent {
final DateTime endTime;
SetRoleEndTimeEvent(this.endTime);
}
class CreateRoleSelectDepartmentEvent extends CreateRoleEvent {
final DepartmentModel item;
CreateRoleSelectDepartmentEvent(this.item);
}
class CreateRoleSelectCountEvent extends CreateRoleEvent {
final int item;
CreateRoleSelectCountEvent(this.item);
}
class CreateRoleSelectBreak extends CreateRoleEvent {
final int item;
CreateRoleSelectBreak(this.item);
}
class CreateRoleSetScheduleEvent extends CreateRoleEvent {
final List<RoleScheduleEntity> schedule;
CreateRoleSetScheduleEvent(this.schedule);
}
class ValidationPositionFailedEvent extends CreateRoleEvent {
final PositionValidationState validationState;
ValidationPositionFailedEvent(this.validationState);
}

View File

@@ -0,0 +1,57 @@
part of 'create_role_bloc.dart';
@immutable
class CreateRoleState {
final bool isExpanded;
final PositionEntity entity;
final PositionValidationState? validationState;
const CreateRoleState({
this.isExpanded = true,
required this.entity,
this.validationState,
});
CreateRoleState copyWith(
{bool? isExpanded,
PositionEntity? entity,
PositionValidationState? validationState}) {
return CreateRoleState(
isExpanded: isExpanded ?? this.isExpanded,
entity: entity ?? this.entity,
validationState: validationState,
);
}
}
class PositionValidationState {
final String? skillError;
final String? departmentError;
final String? countError;
bool showed;
bool get hasError =>
skillError != null || countError != null || departmentError != null;
String? get message {
return skillError ?? countError ?? departmentError ?? '';
}
PositionValidationState(
{this.skillError,
this.countError,
this.departmentError,
this.showed = false});
PositionValidationState copyWith({
String? skillError,
String? countError,
String? departmentError,
}) {
return PositionValidationState(
skillError: skillError ?? this.skillError,
countError: countError ?? this.countError,
departmentError: departmentError ?? this.departmentError,
showed: showed);
}
}

View File

@@ -0,0 +1,329 @@
import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/event_model.dart';
import 'package:krow/core/data/models/event/skill.dart';
import 'package:krow/core/data/models/shift/business_skill_model.dart';
import 'package:krow/core/data/models/shift/department_model.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/kw_time_slot.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_dropdown.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/create_role_section/bloc/create_role_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
import 'package:krow/features/create_event/presentation/role_schedule_dialog/recurring_schedule_widget.dart';
class CreateRoleDetailsWidget extends StatefulWidget {
final String id;
const CreateRoleDetailsWidget({
super.key,
required this.id,
});
@override
State<CreateRoleDetailsWidget> createState() =>
_CreateRoleDetailsWidgetState();
}
class _CreateRoleDetailsWidgetState extends State<CreateRoleDetailsWidget> {
ExpandableController? _expandableController;
@override
void initState() {
super.initState();
_expandableController = ExpandableController(initialExpanded: true);
}
@override
void dispose() {
super.dispose();
_expandableController?.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateEventBloc, CreateEventState>(
builder: (context, eventState) {
return BlocBuilder<CreateShiftDetailsBloc, CreateShiftDetailsState>(
builder: (context, shiftState) {
return BlocConsumer<CreateRoleBloc, CreateRoleState>(
listener: (context, state) {
if (state.isExpanded != _expandableController?.expanded) {
_expandableController!.toggle();
}
},
builder: (context, state) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: KwBoxDecorations.primaryLight8.copyWith(
border: state.validationState != null
? Border.all(color: AppColors.statusError, width: 1)
: null),
child: ExpandableTheme(
data: const ExpandableThemeData(
hasIcon: false,
animationDuration: Duration(milliseconds: 250)),
child: ExpandablePanel(
collapsed: Container(),
controller: _expandableController,
header: _buildHeader(state, shiftState, context),
expanded: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_skillDropDown(state.entity.businessSkill,
eventState.skills, context),
const Gap(8),
_departmentDropDown(state.entity.department,
eventState.departments, context),
const Gap(8),
_timeRow(context, state.entity.startTime,
state.entity.endTime),
const Gap(8),
_countDropDown(state.entity.count, context),
const Gap(8),
_breakDropDown(context, state),
if (eventState.recurringType ==
EventScheduleType.recurring)
const RecurringScheduleWidget(),
const Gap(12),
textRow('Cost',
'\$${state.entity.businessSkill?.price?.toStringAsFixed(2) ?? '0'}'),
const Gap(12),
textRow('Value', '\$${state.entity.price.toStringAsFixed(2)}'),
const Gap(12),
KwButton.accent(
label: 'Save Role',
onPressed: () {
context
.read<CreateRoleBloc>()
.add(ExpandRoleEvent());
}),
const Gap(12),
],
),
),
),
);
},
);
},
);
},
);
}
Widget _buildHeader(CreateRoleState state, CreateShiftDetailsState shiftState,
BuildContext context) {
return GestureDetector(
onTap: () {
context.read<CreateRoleBloc>().add(ExpandRoleEvent());
},
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.only(bottom: 12, top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
state.entity.businessSkill?.skill?.name ?? 'New Role',
style: AppTextStyles.bodyLargeMed,
),
if (!state.isExpanded)
Center(
child: Assets.images.icons.chevronDown.svg(
height: 24,
width: 24,
colorFilter: const ColorFilter.mode(
AppColors.grayStroke,
BlendMode.srcIn,
),
),
),
if (state.isExpanded && shiftState.roles.length > 1)
GestureDetector(
onTap: () {
context
.read<CreateShiftDetailsBloc>()
.add(DeleteRoleDeleteEvent(widget.id));
},
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Assets.images.icons.delete.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.statusError, BlendMode.srcIn),
),
),
),
],
),
),
);
}
_skillDropDown(BusinessSkillModel? selectedSkill,
List<BusinessSkillModel> allSkills, BuildContext context) {
return KwDropdown(
key: ValueKey('skill_key${widget.id}'),
title: 'Role',
hintText: 'Role name',
horizontalPadding: 40,
selectedItem: selectedSkill != null
? KwDropDownItem(
data: selectedSkill, title: selectedSkill.skill?.name ?? '')
: null,
items: allSkills
.map((e) => KwDropDownItem(data: e, title: e.skill?.name ?? '')),
onSelected: (item) {
context.read<CreateRoleBloc>().add(CreateRoleSelectSkillEvent(item));
Future.delayed(
const Duration(milliseconds: 500),
() => context
.read<CreateEventBloc>()
.add(CreateEventEntityUpdatedEvent()));
});
}
_departmentDropDown(DepartmentModel? selected,
List<DepartmentModel> allDepartments, BuildContext context) {
return KwDropdown(
title: 'Department',
hintText: 'Department name',
horizontalPadding: 40,
selectedItem: selected != null
? KwDropDownItem(data: selected, title: selected.name)
: null,
items:
allDepartments.map((e) => KwDropDownItem(data: e, title: e.name)),
onSelected: (item) {
context
.read<CreateRoleBloc>()
.add(CreateRoleSelectDepartmentEvent(item));
});
}
_countDropDown(int? selected, BuildContext context) {
return KwDropdown(
key: ValueKey('count_key${widget.id}'),
title: 'Number of Employee for one Role',
hintText: 'Person count',
horizontalPadding: 40,
selectedItem: selected != null
? KwDropDownItem(data: selected, title: selected.toString())
: null,
items: List.generate(98, (e) => e + 1)
.map((e) => KwDropDownItem(data: e, title: (e).toString())),
onSelected: (item) {
context.read<CreateRoleBloc>().add(CreateRoleSelectCountEvent(item));
Future.delayed(
const Duration(milliseconds: 500),
() {
context
.read<CreateEventBloc>()
.add(CreateEventEntityUpdatedEvent());
});
});
}
_timeRow(context, DateTime? startTime, DateTime? endTime) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: KwTimeSlotInput(
key: ValueKey('start_time_key${widget.id}'),
label: 'Start Time',
initialValue: startTime ?? DateTime(2000,1,1,9,0),
onChange: (value) {
BlocProvider.of<CreateRoleBloc>(context)
.add(SetRoleStartTimeEvent(value));
try {
// Future.delayed(
// const Duration(milliseconds: 100),
// () => context
// .read<CreateEventBloc>()
// .add(CreateEventEntityUpdatedEvent()));
} catch (e) {
print(e);
}
},
),
),
const Gap(8),
Expanded(
child: KwTimeSlotInput(
key: ValueKey('end_time_key${widget.id}'),
label: 'End Time',
initialValue: endTime ?? DateTime(2000,1,1,14,0),
onChange: (value) {
BlocProvider.of<CreateRoleBloc>(context)
.add(SetRoleEndTimeEvent(value));
try {
// Future.delayed(
// const Duration(milliseconds: 100),
// () => context
// .read<CreateEventBloc>()
// .add(CreateEventEntityUpdatedEvent()));
} catch (e) {
print(e);
}
},
),
),
],
);
}
_breakDropDown(BuildContext context,CreateRoleState state) {
String formatBreakDuration(int minutes) {
if (minutes == 60) {
return '1h';
} else if (minutes > 60) {
final hours = minutes ~/ 60;
final remainder = minutes % 60;
return remainder == 0 ? '${hours}h' : '${hours}h ${remainder} min';
}
return '${minutes} min';
}
return KwDropdown(
key: ValueKey('break_key${widget.id}'),
title: 'Break Duration',
hintText: 'Duration',
horizontalPadding: 40,
selectedItem: KwDropDownItem(data: state.entity.breakDuration, title: formatBreakDuration(state.entity.breakDuration??0)),
items: [15, 30].map((e) {
return KwDropDownItem(data: e, title: formatBreakDuration(e));
}),
onSelected: (item) {
context.read<CreateRoleBloc>().add(CreateRoleSelectBreak(item??0));
});
}
Widget textRow(String key, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(key,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
Text(value, style: AppTextStyles.bodyMediumMed),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/create_event/presentation/create_role_section/create_role_widget.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
class CreateRolesList extends StatefulWidget {
final String id;
const CreateRolesList({super.key, required this.id});
@override
State<CreateRolesList> createState() => _CreateRolesListState();
}
class _CreateRolesListState extends State<CreateRolesList> {
final Map<String, GlobalKey> roleKeys = {};
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateShiftDetailsBloc, CreateShiftDetailsState>(
builder: (context, state) {
return ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
primary: false,
itemCount: state.roles.length,
itemBuilder: (context, index) {
final roleId = state.roles[index].id;
if (roleKeys[roleId] == null) {
roleKeys[roleId] = GlobalKey();
}
return BlocProvider.value(
value: state.roles[index].bloc,
child: CreateRoleDetailsWidget(
key: roleKeys[roleId], id: state.roles[index].id),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,125 @@
import 'package:bloc/bloc.dart';
import 'package:krow/core/data/models/event/business_member_model.dart';
import 'package:krow/core/data/models/event/full_address_model.dart';
import 'package:krow/core/entity/position_entity.dart';
import 'package:krow/core/entity/shift_entity.dart';
import 'package:krow/features/create_event/domain/google_places_service.dart';
import 'package:krow/features/create_event/presentation/create_role_section/bloc/create_role_bloc.dart';
import 'package:meta/meta.dart';
part 'create_shift_details_event.dart';
part 'create_shift_details_state.dart';
class CreateShiftDetailsBloc
extends Bloc<CreateShiftDetailsEvent, CreateShiftDetailsState> {
CreateShiftDetailsBloc({required bool expanded})
: super(CreateShiftDetailsState(
suggestions: [],
isExpanded: expanded,
shift: ShiftEntity.empty(),
roles: [
RoleViewModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
bloc: CreateRoleBloc()),
])) {
on<CreateShiftInitializeEvent>(_onInitialize);
on<CreateShiftAddressQueryChangedEvent>(_onQueryChanged);
on<CreateShiftAddressSelectEvent>(_onAddressSelect);
on<CreateShiftSelectContactEvent>(_onSelectContact);
on<CreateShiftRemoveContactEvent>(_onRemoveContact);
on<CreateEventAddRoleEvent>(_onAddRole);
on<DeleteRoleDeleteEvent>(_onDeleteRole);
on<ExpandShiftEvent>(_onExpandShift);
on<ValidationFailedEvent>((event, emit) {
emit(state.copyWith(validationState: event.validationState));
});
}
void _onInitialize(CreateShiftInitializeEvent event, emit) async {
emit(CreateShiftDetailsState(
suggestions: [],
isExpanded: state.isExpanded,
shift: event.shift,
roles: [
...event.shift.positions.map((roleEntity) => RoleViewModel(
id: roleEntity.id,
bloc: CreateRoleBloc()
..add(
CreateRoleInitEvent(roleEntity),
))),
]));
}
void _onQueryChanged(CreateShiftAddressQueryChangedEvent event, emit) async {
try {
final googlePlacesService = GooglePlacesService();
final suggestions =
await googlePlacesService.fetchSuggestions(event.query);
emit(state.copyWith(suggestions: suggestions));
} catch (e) {
print(e);
}
}
void _onAddressSelect(CreateShiftAddressSelectEvent event, emit) async {
if (event.address != null) {
emit(state.copyWith(
suggestions: [],
shift: state.shift.copyWith(fullAddress: event.address!)));
return;
}
if (event.place != null) {
final googlePlacesService = GooglePlacesService();
final fullAddress =
await googlePlacesService.getPlaceDetails(event.place!.placeId);
FullAddress address = FullAddress.fromGoogle(fullAddress);
emit(state.copyWith(
suggestions: [], shift: state.shift.copyWith(fullAddress: address)));
return;
}
}
void _onSelectContact(CreateShiftSelectContactEvent event, emit) {
emit(state.copyWith(
shift: state.shift
.copyWith(managers: [...state.shift.managers, event.contact])));
}
void _onRemoveContact(CreateShiftRemoveContactEvent event, emit) {
emit(state.copyWith(
shift: state.shift.copyWith(
managers: state.shift.managers
.where((element) => element != event.contact)
.toList())));
}
void _onAddRole(CreateEventAddRoleEvent event, emit) {
final id = DateTime.now().millisecondsSinceEpoch.toString();
final bloc = CreateRoleBloc();
PositionEntity newPosition = PositionEntity.empty();
bloc.add(CreateRoleInitEvent(newPosition));
newPosition.parentShift = state.shift;
state.shift.positions.add(newPosition);
emit(state.copyWith(
roles: [
...state.roles,
RoleViewModel(id: id, bloc: bloc),
],
));
}
void _onDeleteRole(DeleteRoleDeleteEvent event, emit) {
emit(state.copyWith(
roles: state.roles.where((element) => element.id != event.id).toList(),
));
}
void _onExpandShift(ExpandShiftEvent event, emit) {
emit(state.copyWith(isExpanded: !state.isExpanded));
}
}

View File

@@ -0,0 +1,55 @@
part of 'create_shift_details_bloc.dart';
@immutable
sealed class CreateShiftDetailsEvent {}
class CreateShiftInitializeEvent extends CreateShiftDetailsEvent {
final ShiftEntity shift;
CreateShiftInitializeEvent(this.shift);
}
class CreateShiftAddressSelectEvent extends CreateShiftDetailsEvent {
final MapPlace? place;
final FullAddress? address;
CreateShiftAddressSelectEvent({this.place, this.address});
}
class CreateShiftAddressQueryChangedEvent extends CreateShiftDetailsEvent {
final String query;
CreateShiftAddressQueryChangedEvent(this.query);
}
class CreateShiftSelectContactEvent extends CreateShiftDetailsEvent {
final BusinessMemberModel contact;
CreateShiftSelectContactEvent(this.contact);
}
class CreateShiftRemoveContactEvent extends CreateShiftDetailsEvent {
final BusinessMemberModel contact;
CreateShiftRemoveContactEvent(this.contact);
}
class CreateEventAddRoleEvent extends CreateShiftDetailsEvent {
CreateEventAddRoleEvent();
}
class DeleteRoleDeleteEvent extends CreateShiftDetailsEvent {
final String id;
DeleteRoleDeleteEvent(this.id);
}
class ExpandShiftEvent extends CreateShiftDetailsEvent {
ExpandShiftEvent();
}
class ValidationFailedEvent extends CreateShiftDetailsEvent {
final ShiftValidationState validationState;
ValidationFailedEvent(this.validationState);
}

View File

@@ -0,0 +1,79 @@
part of 'create_shift_details_bloc.dart';
@immutable
class CreateShiftDetailsState {
final bool inLoading;
final bool isExpanded;
final ShiftEntity shift;
final List<MapPlace> suggestions;
final List<RoleViewModel> roles;
final ShiftValidationState? validationState;
const CreateShiftDetailsState({
required this.shift,
this.inLoading = false,
required this.suggestions,
required this.roles,
this.isExpanded = true,
this.validationState
});
CreateShiftDetailsState copyWith({
bool? inLoading,
List<MapPlace>? suggestions,
ShiftEntity? shift,
String? department,
List<RoleViewModel>? roles,
bool? isExpanded,
ShiftValidationState? validationState,
}) {
return CreateShiftDetailsState(
shift: shift ?? this.shift,
inLoading: inLoading ?? false,
suggestions: suggestions ?? this.suggestions,
roles: roles ?? this.roles,
isExpanded: isExpanded ?? this.isExpanded,
validationState: validationState,
);
}
}
class RoleViewModel {
final String id;
final CreateRoleBloc bloc;
RoleViewModel({required this.id, required this.bloc});
}
class ShiftValidationState {
final String? addressError;
final String? contactsError;
bool showed;
bool get hasError =>
addressError != null ||
contactsError != null;
String? get message {
return addressError ?? contactsError ?? '';
}
ShiftValidationState({
this.addressError,
this.contactsError,this.showed = false});
ShiftValidationState copyWith({
String? addressError,
String? contactsError,
}) {
return ShiftValidationState(
addressError: addressError ?? this.addressError,
contactsError: contactsError ?? this.contactsError,
showed: showed
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:expandable/expandable.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_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/core/presentation/widgets/ui_kit/kw_suggestion_input.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/domain/google_places_service.dart';
import 'package:krow/features/create_event/presentation/create_role_section/create_roles_list.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/widgets/shift_contacts_widget.dart';
class CreateShiftDetailsWidget extends StatefulWidget {
final String id;
final int index;
final bool expanded;
const CreateShiftDetailsWidget({
super.key,
required this.id,
required this.index,
this.expanded = true,
});
@override
State<CreateShiftDetailsWidget> createState() =>
_CreateShiftDetailsWidgetState();
}
class _CreateShiftDetailsWidgetState extends State<CreateShiftDetailsWidget>
with AutomaticKeepAliveClientMixin {
ExpandableController? _expandableController;
@override
bool get wantKeepAlive => true;
@override
void initState() {
_expandableController =
ExpandableController(initialExpanded: widget.expanded);
super.initState();
}
@override
void dispose() {
super.dispose();
_expandableController?.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return BlocBuilder<CreateEventBloc, CreateEventState>(
builder: (context, eventState) {
return BlocConsumer<CreateShiftDetailsBloc, CreateShiftDetailsState>(
listener: (context, state) {
if (state.isExpanded != _expandableController?.expanded) {
_expandableController!.toggle();
}
},
builder: (context, state) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.white12.copyWith(
border: state.validationState != null
? Border.all(color: AppColors.statusError, width: 1)
: null),
child: ExpandableTheme(
data: const ExpandableThemeData(
hasIcon: false,
animationDuration: Duration(milliseconds: 250)),
child: ExpandablePanel(
collapsed: Container(),
controller: _expandableController,
header: _buildHeader(
context, state, eventState.shifts.length > 1),
expanded: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_addressInput(state, context),
ShiftContactsWidget(
allContacts: eventState.contacts,
selectedContacts: state.shift?.managers ?? []),
CreateRolesList(id: widget.id),
const Gap(12),
KwButton.outlinedPrimary(
label: 'Add New Role',
leftIcon: Assets.images.icons.add,
onPressed: () {
context
.read<CreateShiftDetailsBloc>()
.add(CreateEventAddRoleEvent());
}),
const Gap(12),
],
),
),
),
);
},
);
},
);
}
Widget _buildHeader(
BuildContext context,
CreateShiftDetailsState state,
bool canBeRemoved,
) {
return GestureDetector(
onTap: () {
context.read<CreateShiftDetailsBloc>().add(ExpandShiftEvent());
},
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Shift Details #${widget.index + 1}',
style: AppTextStyles.bodyMediumMed,
),
if (!state.isExpanded)
Center(
child: Assets.images.icons.chevronDown.svg(
height: 24,
width: 24,
colorFilter: const ColorFilter.mode(
AppColors.grayStroke,
BlendMode.srcIn,
),
),
),
if (state.isExpanded && canBeRemoved)
GestureDetector(
onTap: () {
context
.read<CreateEventBloc>()
.add(CreateEventRemoveShift(widget.id));
},
child: Container(
height: 24,
width: 24,
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(
child: Assets.images.icons.delete.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.statusError, BlendMode.srcIn),
),
),
),
),
)
],
),
),
);
}
KwSuggestionInput<MapPlace> _addressInput(
CreateShiftDetailsState state, BuildContext context) {
state.suggestions.removeWhere(
(e) => e.description == state.shift.fullAddress?.formattedAddress);
return KwSuggestionInput(
key: ValueKey('address_key${widget.id}'),
title: 'Address',
hintText: 'Hub address',
horizontalPadding: 28,
initialText: state.shift.fullAddress?.formattedAddress,
items: state.suggestions,
onQueryChanged: (query) {
context
.read<CreateShiftDetailsBloc>()
.add(CreateShiftAddressQueryChangedEvent(query));
},
itemToStringBuilder: (item) => item.description,
onSelected: (item) {
context
.read<CreateShiftDetailsBloc>()
.add(CreateShiftAddressSelectEvent(place: item));
},
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/create_shift_widget.dart';
class CreateShiftsList extends StatefulWidget {
const CreateShiftsList({super.key});
@override
State<CreateShiftsList> createState() => _CreateShiftsListState();
}
class _CreateShiftsListState extends State<CreateShiftsList> {
final Map<String, GlobalKey> shiftKeys = {};
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateEventBloc, CreateEventState>(
builder: (context, state) {
return SliverList.builder(
itemCount: state.shifts.length,
itemBuilder: (context, index) {
final shiftId = state.shifts[index].id;
if (shiftKeys[shiftId] == null) {
shiftKeys[shiftId] = GlobalKey();
}
return BlocProvider.value(
value: state.shifts[index].bloc,
child: CreateShiftDetailsWidget(
key: shiftKeys[shiftId],
id: state.shifts[index].id,
expanded: state.shifts[index].bloc.state.isExpanded,
index: index),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/business_member_model.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';
class SelectContactPopup extends StatefulWidget {
final List<BusinessMemberModel> contacts;
final void Function(BusinessMemberModel contact)? onSelect;
final Widget child;
const SelectContactPopup(
{super.key,
required this.contacts,
required this.onSelect,
required this.child});
@override
_SelectContactPopupState createState() => _SelectContactPopupState();
}
class _SelectContactPopupState extends State<SelectContactPopup> {
OverlayEntry? _overlayEntry;
List<BusinessMemberModel> _filteredItems = [];
final ScrollController _scrollController = ScrollController();
final TextEditingController _controller = TextEditingController();
final GlobalKey _childKey = GlobalKey();
double? childY;
StateSetter? overlaySetState;
@override
initState() {
super.initState();
}
void _showPopup(BuildContext context) {
_filteredItems = List.from(widget.contacts);
_overlayEntry = OverlayEntry(
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
overlaySetState = setState;
return Stack(
children: [
GestureDetector(
onTap: _hidePopup,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
if (childY != null)
Positioned(
height:
min(320, 82 + (44 * _filteredItems.length).toDouble()),
top: childY,
left: 28,
right: 28,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(10),
child: _buildPopupContent(),
),
),
],
);
});
},
);
Overlay.of(context).insert(_overlayEntry!);
}
Widget _buildPopupContent() {
return Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.grayTintStroke),
),
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AppColors.grayPrimaryFrame,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Assets.images.icons.magnifyingGlass.svg(),
const Gap(8),
Expanded(
child: TextField(
controller: _controller,
style: AppTextStyles.bodyLargeMed,
decoration: const InputDecoration(
contentPadding: EdgeInsets.only(bottom: 8),
hintText: 'Search',
hintStyle: AppTextStyles.bodyLargeMed,
border: InputBorder.none,
),
onChanged: _filterItems,
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
height: min(238, (44 * _filteredItems.length).toDouble()),
child: RawScrollbar(
controller: _scrollController,
thumbVisibility: true,
padding: const EdgeInsets.only(right: 4),
thumbColor: AppColors.grayDisable,
trackColor: AppColors.buttonTertiaryActive,
trackVisibility: true,
radius: const Radius.circular(20),
thickness: 4,
child: ListView.builder(
controller: _scrollController,
itemCount: _filteredItems.length,
shrinkWrap: true,
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
var item = _filteredItems[index];
return GestureDetector(
onTap: () {
widget.onSelect?.call(item);
_hidePopup();
},
child: Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey,
),
),
const Gap(8),
Text(
'${item.firstName} ${item.lastName}',
style: AppTextStyles.bodyMediumMed,
),
const Gap(8),
Text(
item.authInfo?.phone ?? '',
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.blackGray),
),
],
),
),
);
},
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
final RenderBox renderBox =
_childKey.currentContext!.findRenderObject() as RenderBox;
final position = renderBox.localToGlobal(Offset.zero);
childY = position.dy;
_showPopup(context);
},
key: _childKey,
child: widget.child);
}
void _hidePopup() {
_overlayEntry?.remove();
_overlayEntry = null;
_filteredItems = List.from(widget.contacts);
_controller.clear();
}
void _filterItems(String query) {
overlaySetState?.call(() {
_filteredItems = widget.contacts.where((item) {
return ('${item.firstName}${item.lastName}${item.authInfo?.phone ?? ''}')
.toLowerCase()
.contains(query.toLowerCase());
}).toList();
});
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/business_member_model.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/create_event/presentation/create_shift_details_section/bloc/create_shift_details_bloc.dart';
import 'package:krow/features/create_event/presentation/create_shift_details_section/widgets/select_contact_popup.dart';
class ShiftContactsWidget extends StatelessWidget {
final List<BusinessMemberModel> allContacts;
final List<BusinessMemberModel> selectedContacts;
const ShiftContactsWidget(
{super.key, required this.allContacts, required this.selectedContacts});
@override
Widget build(BuildContext context) {
if (selectedContacts.isEmpty) {
return buildSelectContactPopup(context);
}
return Container(
padding: const EdgeInsets.only(top: 12, left: 12, right: 12),
margin: const EdgeInsets.only(top: 12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Shift Contact',
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.blackGray)),
for (var item in selectedContacts)
_buildContactItem(context, item, selectedContacts),
buildSelectContactPopup(context),
],
));
}
Widget buildSelectContactPopup(BuildContext context) {
return SelectContactPopup(
key: ObjectKey(selectedContacts),
contacts: allContacts
.where((element) => !selectedContacts.contains(element))
.toList(),
onSelect: (contact) {
BlocProvider.of<CreateShiftDetailsBloc>(context)
.add(CreateShiftSelectContactEvent(contact));
},
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 12),
height: 20,
child: Row(
children: [
Assets.images.icons.profileAdd.svg(),
const Gap(8),
Text(
'Shift Contact',
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.primaryBlue),
),
],
),
),
);
}
Container _buildContactItem(BuildContext context, BusinessMemberModel item,
List<BusinessMemberModel> selectedContacts) {
return Container(
margin: const EdgeInsets.only(top: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 36,
width: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18), color: Colors.grey),
),
const Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.firstName} ${item.lastName}',
style: AppTextStyles.bodyMediumMed,
),
const Gap(2),
Text(
item.authInfo?.phone ?? '',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
],
),
const Spacer(),
SelectContactPopup(
key: ObjectKey(selectedContacts),
contacts: allContacts
.where((element) => !selectedContacts.contains(element))
.toList(),
onSelect: (contact) {
BlocProvider.of<CreateShiftDetailsBloc>(context)
.add(CreateShiftRemoveContactEvent(item));
BlocProvider.of<CreateShiftDetailsBloc>(context)
.add(CreateShiftSelectContactEvent(contact));
},
child: Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(17),
border: Border.all(color: AppColors.grayTintStroke)),
child: Row(
children: [
Center(
child: Assets.images.icons.edit.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.blackBlack, BlendMode.srcIn),
),
),
const Gap(4),
const Text(
'Edit',
style: AppTextStyles.bodyMediumMed,
)
],
),
),
),
const Gap(4),
GestureDetector(
onTap: () {
BlocProvider.of<CreateShiftDetailsBloc>(context)
.add(CreateShiftRemoveContactEvent(item));
},
child: Container(
height: 34,
width: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(17),
border: Border.all(color: AppColors.grayTintStroke)),
child: Center(
child: Assets.images.icons.delete.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.statusError, BlendMode.srcIn),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:bloc/bloc.dart';
import 'package:krow/core/data/models/event/event_model.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:meta/meta.dart';
part 'date_selector_event.dart';
part 'date_selector_state.dart';
class DateSelectorBloc extends Bloc<DateSelectorEvent, DateSelectorState> {
DateSelectorBloc() : super(const DateSelectorState()) {
on<DateSelectorEventInit>(_onInit);
on<DateSelectorEventChangeStartDate>(_onChangeStartDate);
on<DateSelectorEventChangeEndDate>(_onChangeEndDate);
on<DateSelectorEventRepeatType>(_onChangeRepeatType);
on<DateSelectorEventEndType>(_onChangeEndType);
on<DateSelectorEventChangeEndsAfterWeeks>(_onChangeEndsAfterWeeks);
}
void _onInit(DateSelectorEventInit event, Emitter<DateSelectorState> emit) {
if (event.eventModel != null) {
emit(state.copyWith(
startDate: DateTime.parse(event.eventModel!.date),
));
return;
} else {
emit(state.copyWith(
recurringType: event.recurringType,
endDateType: EndDateType.endDate,
repeatType: EventRepeatType.daily,
));
}
}
void _onChangeStartDate(
DateSelectorEventChangeStartDate event, Emitter<DateSelectorState> emit) {
if (event.startDate == state.startDate) return;
event.entity.startDate = event.startDate;
emit(state.copyWith(
startDate: event.startDate,
));
}
void _onChangeEndDate(
DateSelectorEventChangeEndDate event, Emitter<DateSelectorState> emit) {
if (event.endDate == state.startDate) return;
event.entity.endDate = event.endDate;
emit(state.copyWith(
endDate: event.endDate,
));
}
void _onChangeRepeatType(
DateSelectorEventRepeatType event, Emitter<DateSelectorState> emit) {
if (event.type == state.repeatType) return;
emit(state.copyWith(
repeatType: event.type,
));
}
void _onChangeEndType(
DateSelectorEventEndType event, Emitter<DateSelectorState> emit) {
if (event.type == state.endDateType) return;
emit(state.copyWith(
endDateType: event.type,
));
}
void _onChangeEndsAfterWeeks(DateSelectorEventChangeEndsAfterWeeks event,
Emitter<DateSelectorState> emit) {
if (event.weeks == state.andsAfterWeeksCount) return;
emit(state.copyWith(
andsAfterWeeksCount: event.weeks,
));
}
}

View File

@@ -0,0 +1,44 @@
part of 'date_selector_bloc.dart';
@immutable
sealed class DateSelectorEvent {}
class DateSelectorEventInit extends DateSelectorEvent {
final EventScheduleType? recurringType;
final EventModel? eventModel;
DateSelectorEventInit(this.recurringType, this.eventModel);
}
class DateSelectorEventChangeStartDate extends DateSelectorEvent {
final DateTime startDate;
final EventEntity entity;
DateSelectorEventChangeStartDate(this.startDate, this.entity);
}
class DateSelectorEventChangeEndDate extends DateSelectorEvent {
final DateTime endDate;
final EventEntity entity;
DateSelectorEventChangeEndDate(this.endDate, this.entity);
}
class DateSelectorEventRepeatType extends DateSelectorEvent {
final EventRepeatType type;
DateSelectorEventRepeatType(this.type);
}
class DateSelectorEventEndType extends DateSelectorEvent {
final EndDateType type;
DateSelectorEventEndType(this.type);
}
class DateSelectorEventChangeEndsAfterWeeks extends DateSelectorEvent {
final int weeks;
DateSelectorEventChangeEndsAfterWeeks(this.weeks);
}

View File

@@ -0,0 +1,44 @@
part of 'date_selector_bloc.dart';
enum EndDateType { endDate, endsAfter }
enum EventRepeatType {
weekly,
daily,
}
@immutable
class DateSelectorState {
final DateTime? startDate;
final DateTime? endDate;
final EventScheduleType? recurringType;
final EndDateType? endDateType;
final EventRepeatType? repeatType;
final int andsAfterWeeksCount;
const DateSelectorState(
{this.startDate,
this.endDate,
this.recurringType = EventScheduleType.oneTime,
this.endDateType,
this.repeatType,
this.andsAfterWeeksCount = 1});
DateSelectorState copyWith({
DateTime? startDate,
DateTime? endDate,
EventScheduleType? recurringType,
EndDateType? endDateType,
EventRepeatType? repeatType,
int? andsAfterWeeksCount,
}) {
return DateSelectorState(
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
recurringType: recurringType ?? this.recurringType,
endDateType: endDateType ?? this.endDateType,
repeatType: repeatType ?? this.repeatType,
andsAfterWeeksCount: andsAfterWeeksCount ?? this.andsAfterWeeksCount,
);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:calendar_date_picker2/calendar_date_picker2.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';
class CreateEventCalendar extends StatefulWidget {
final DateTime? initialDate;
final void Function(DateTime value) onDateSelected;
const CreateEventCalendar(
{super.key, required this.initialDate, required this.onDateSelected});
@override
State<CreateEventCalendar> createState() => _CreateEventCalendarState();
}
class _CreateEventCalendarState extends State<CreateEventCalendar> {
final monthStr = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
final selectedTextStyle = AppTextStyles.bodyMediumSmb.copyWith(
color: AppColors.grayWhite,
);
final dayTextStyle = AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.bgColorDark,
);
@override
Widget build(BuildContext context) {
return CalendarDatePicker2(
value: widget.initialDate == null ? [] : [widget.initialDate],
config: CalendarDatePicker2Config(
hideMonthPickerDividers: false,
modePickersGap: 0,
controlsHeight: 80,
calendarType: CalendarDatePicker2Type.single,
selectedRangeHighlightColor: AppColors.tintGray,
selectedDayTextStyle: selectedTextStyle,
dayTextStyle: dayTextStyle,
selectedMonthTextStyle: selectedTextStyle,
selectedYearTextStyle: selectedTextStyle,
selectedDayHighlightColor: AppColors.bgColorDark,
centerAlignModePicker: true,
monthTextStyle: dayTextStyle,
weekdayLabelBuilder: _dayWeekBuilder,
dayBuilder: _dayBuilder,
monthBuilder: _monthBuilder,
yearBuilder: _yearBuilder,
controlsTextStyle: AppTextStyles.headingH3,
nextMonthIcon: Assets.images.icons.caretRight.svg(width: 24),
lastMonthIcon: Assets.images.icons.caretLeft.svg(width: 24),
customModePickerIcon: Padding(
padding: const EdgeInsets.only(left: 4),
child: Assets.images.icons.caretDown.svg(),
),
// modePickerBuilder: _controlBuilder,
),
onValueChanged: (dates) {
widget.onDateSelected.call(dates.first);
},
);
}
Widget? _monthBuilder({
required int month,
TextStyle? textStyle,
BoxDecoration? decoration,
bool? isSelected,
bool? isDisabled,
bool? isCurrentMonth,
}) {
return Center(
child: Container(
margin: const EdgeInsets.only(top: 16),
height: 52,
decoration: BoxDecoration(
color: isSelected == true ? AppColors.bgColorDark : null,
borderRadius: BorderRadius.circular(23),
border: Border.all(
color: AppColors.grayStroke,
width: isSelected == true ? 0 : 1,
),
),
child: Center(
child: Text(
monthStr[month - 1],
style: isSelected == true
? AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.grayWhite)
: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
),
),
);
}
Widget? _yearBuilder({
required int year,
TextStyle? textStyle,
BoxDecoration? decoration,
bool? isSelected,
bool? isDisabled,
bool? isCurrentYear,
}) {
return Container(
margin: const EdgeInsets.only(top: 12),
height: 52,
decoration: BoxDecoration(
color: isSelected == true ? AppColors.bgColorDark : null,
borderRadius: BorderRadius.circular(23),
border: Border.all(
color: AppColors.grayStroke,
width: isSelected == true ? 0 : 1,
),
),
child: Center(
child: Text(
year.toString(),
style: isSelected == true
? AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite)
: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
),
);
}
Widget? _dayBuilder({
required DateTime date,
TextStyle? textStyle,
BoxDecoration? decoration,
bool? isSelected,
bool? isDisabled,
bool? isToday,
}) {
bool past = _isPast(date);
var dayDecoration = BoxDecoration(
color: isSelected == true ? AppColors.bgColorDark : null,
borderRadius: BorderRadius.circular(20),
);
var dayTextStyle = AppTextStyles.bodyMediumReg.copyWith(
color: past ? AppColors.blackCaptionText : AppColors.bgColorDark,
);
return Center(
child: Container(
margin: const EdgeInsets.only(left: 2, right: 2),
alignment: Alignment.center,
decoration: dayDecoration,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
date.day.toString(),
style: isSelected == true ? selectedTextStyle : dayTextStyle,
textAlign: TextAlign.center,
),
const Gap(2),
],
),
),
);
}
bool _isPast(DateTime date) {
var now = DateTime.now();
var nowOnly = DateTime(now.year, now.month, now.day);
var past = date.isBefore(nowOnly);
return past;
}
Widget? _dayWeekBuilder({
required int weekday,
bool? isScrollViewTopHeader,
}) {
return Text(
['S', 'M', 'T', 'W', 'T', 'F', 'S'][weekday],
style: AppTextStyles.bodyMediumSmb.copyWith(
color: AppColors.bgColorDark,
),
textAlign: TextAlign.center,
);
}
}

View File

@@ -0,0 +1,109 @@
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/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/create_event/presentation/event_date_section/create_event_calendar_widget.dart';
class CreateEventDatePopup extends StatefulWidget {
final DateTime? initDate;
const CreateEventDatePopup({
super.key,
this.initDate,
});
@override
State<CreateEventDatePopup> createState() => _CreateEventDatePopupState();
static Future<DateTime?> show(
{required BuildContext context, DateTime? initDate}) async {
return showDialog<DateTime>(
context: context,
builder: (context) => CreateEventDatePopup(
initDate: initDate,
),
);
}
}
class _CreateEventDatePopupState extends State<CreateEventDatePopup> {
DateTime? selectedDate;
@override
void initState() {
selectedDate = widget.initDate;
super.initState();
}
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
margin: const EdgeInsets.all(16),
decoration: KwBoxDecorations.white24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
buildHeader(),
const Gap(24),
_buildCalendar(),
const Gap(24),
..._buttonGroup(),
],
),
),
),
);
}
Row buildHeader() {
return const Row(
children: [
Text('Select Date from Calendar', style: AppTextStyles.headingH3),
],
);
}
List<Widget> _buttonGroup() {
return [
KwButton.primary(
disabled: selectedDate == null,
label: 'Pick Date',
onPressed: () {
Navigator.of(context).pop(selectedDate);
},
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'Cancel',
onPressed: () {
Navigator.of(context).pop(widget.initDate);
},
),
];
}
_buildCalendar() {
return Container(
decoration: BoxDecoration(
color: AppColors.grayPrimaryFrame,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.grayTintStroke),
),
child: CreateEventCalendar(
onDateSelected: (date) {
setState(() {
selectedDate = date;
});
},
initialDate: selectedDate ?? widget.initDate,
),
);
}
}

View File

@@ -0,0 +1,196 @@
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/data/models/event/event_model.dart';
import 'package:krow/core/entity/event_entity.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_dropdown.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_option_selector.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/event_date_section/bloc/date_selector_bloc.dart';
import 'package:krow/features/create_event/presentation/event_date_section/create_event_date_popup.dart';
class EventDateInputWidget extends StatelessWidget {
final EventEntity entity;
const EventDateInputWidget(this.entity, {super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<DateSelectorBloc, DateSelectorState>(
listener: (context, state) {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventEntityUpdatedEvent());
},
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: state.recurringType == EventScheduleType.oneTime
? ([
_buildDateInput(context, 'Date', state.startDate, (newDate) {
BlocProvider.of<DateSelectorBloc>(context)
.add(DateSelectorEventChangeStartDate(newDate, entity));
}),
])
: [
const Gap(16),
const Divider(
color: AppColors.grayTintStroke,
thickness: 1,
height: 0,
),
_buildDateInput(context, 'Start Date', state.startDate,
(newDate) {
BlocProvider.of<DateSelectorBloc>(context)
.add(DateSelectorEventChangeStartDate(newDate, entity));
}),
..._buildRepeatSelector(state, context),
...buildEndTypeSelector(state, context),
AnimatedCrossFade(
firstChild: _buildDateInput(
context, 'End Date', state.endDate, (newDate) {
BlocProvider.of<DateSelectorBloc>(context)
.add(DateSelectorEventChangeEndDate(newDate, entity));
}),
secondChild: _buildEndAfterDropdown(state, context),
crossFadeState: state.endDateType == EndDateType.endDate
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
const Gap(16),
const Divider(
color: AppColors.grayTintStroke,
thickness: 1,
height: 0,
),
const Gap(8),
],
);
},
);
}
Widget _buildEndAfterDropdown(DateSelectorState state, BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: KwDropdown<int>(
selectedItem: KwDropDownItem(
data: state.andsAfterWeeksCount,
title: '${state.andsAfterWeeksCount} Weeks'),
hintText: '',
title: 'After',
horizontalPadding: 28,
onSelected: (int item) {
BlocProvider.of<DateSelectorBloc>(context)
.add(DateSelectorEventChangeEndsAfterWeeks(item));
},
items: const [
KwDropDownItem(data: 1, title: '1 Weeks'),
KwDropDownItem(data: 2, title: '2 Weeks'),
KwDropDownItem(data: 3, title: '3 Weeks'),
KwDropDownItem(data: 5, title: '4 Weeks'),
],
),
);
}
List<Widget> _buildRepeatSelector(
DateSelectorState state, BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 4),
child: Text(
'Repeat',
style: AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
),
KwOptionSelector(
selectedIndex: state.repeatType?.index,
backgroundColor: AppColors.grayPrimaryFrame,
onChanged: (index) {
BlocProvider.of<DateSelectorBloc>(context).add(
DateSelectorEventRepeatType(EventRepeatType.values[index]));
},
items: const [
'Weekly',
'Daily',
])
];
}
Widget _buildDateInput(BuildContext context, String title, DateTime? date,
Function(DateTime date) onSelect) {
var formattedDate = date == null
? 'mm.dd.yyyy'
: DateFormat('MM.dd.yyyy').format(date).toLowerCase();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 8),
child: Text(
'Date',
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
),
const Gap(4),
GestureDetector(
onTap: () async {
var newDate = await CreateEventDatePopup.show(
context: context, initDate: date);
if (newDate != null && context.mounted) {
onSelect(newDate);
}
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.grayStroke),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formattedDate,
style: AppTextStyles.bodyMediumReg.copyWith(
color: date != null ? null : AppColors.blackGray)),
const Gap(10),
Assets.images.icons.calendar.svg(width: 16, height: 16),
],
),
),
),
],
);
}
List<Widget> buildEndTypeSelector(
DateSelectorState state, BuildContext context) {
return [
const Gap(24),
KwOptionSelector(
selectedIndex: state.endDateType?.index,
onChanged: (index) {
BlocProvider.of<DateSelectorBloc>(context)
.add(DateSelectorEventEndType(EndDateType.values[index]));
},
height: 26,
selectorHeight: 4,
textStyle:
AppTextStyles.bodyMediumReg.copyWith(color: AppColors.blackGray),
selectedTextStyle: AppTextStyles.bodyMediumMed,
itemAlign: Alignment.topCenter,
items: const [
'Ends on Date',
'Ends After # Weeks',
])
];
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:krow/core/entity/role_schedule_entity.dart';
import 'package:meta/meta.dart';
part 'role_schedule_dialog_event.dart';
part 'role_schedule_dialog_state.dart';
class RoleScheduleDialogBloc
extends Bloc<RoleScheduleDialogEvent, RoleScheduleDialogState> {
RoleScheduleDialogBloc(List<RoleScheduleEntity> schedule)
: super(RoleScheduleDialogState(schedule: List.from(schedule))) {
on<TapOnScheduleDayEvent>(_onTapOnScheduleDay);
on<SetRoleScheduleStartTimeEvent>(_onSetRoleScheduleStartTime);
on<SetRoleScheduleEndTimeEvent>(_onSetRoleScheduleEndTime);
}
FutureOr<void> _onTapOnScheduleDay(TapOnScheduleDayEvent event, emit) {
if (state.schedule.any((element) => element.dayIndex == event.index)) {
emit(state.copyWith(
schedule: state.schedule
.where((element) => element.dayIndex != event.index)
.toList()));
} else {
var today = DateTime.now();
var defStartTime = DateTime(today.year, today.month, today.day, 9, 0);
var defEndTime = DateTime(today.year, today.month, today.day, 18, 0);
emit(state.copyWith(schedule: [
...state.schedule,
RoleScheduleEntity(
dayIndex: event.index, startTime: defStartTime, endTime: defEndTime)
]));
}
}
FutureOr<void> _onSetRoleScheduleStartTime(event, emit) {
emit(state.copyWith(
schedule: state.schedule
.map((e) => e.dayIndex == event.schedule.dayIndex
? e.copyWith(startTime: event.startTime)
: e)
.toList()));
}
FutureOr<void> _onSetRoleScheduleEndTime(event, emit) {
emit(state.copyWith(
schedule: state.schedule
.map((e) => e.dayIndex == event.schedule.dayIndex
? e.copyWith(endTime: event.endTime)
: e)
.toList()));
}
}

View File

@@ -0,0 +1,28 @@
part of 'role_schedule_dialog_bloc.dart';
@immutable
sealed class RoleScheduleDialogEvent {}
class TapOnScheduleDayEvent extends RoleScheduleDialogEvent {
final int index;
TapOnScheduleDayEvent(this.index);
}
class SetRoleScheduleStartTimeEvent extends RoleScheduleDialogEvent {
final RoleScheduleEntity schedule;
final DateTime startTime;
SetRoleScheduleStartTimeEvent(this.schedule, this.startTime);
}
class SetRoleScheduleEndTimeEvent extends RoleScheduleDialogEvent {
final RoleScheduleEntity schedule;
final DateTime endTime;
SetRoleScheduleEndTimeEvent(this.schedule, this.endTime);
}
class SaveRoleScheduleEvent extends RoleScheduleDialogEvent {
SaveRoleScheduleEvent();
}

View File

@@ -0,0 +1,14 @@
part of 'role_schedule_dialog_bloc.dart';
@immutable
class RoleScheduleDialogState {
final List<RoleScheduleEntity> schedule;
const RoleScheduleDialogState({required this.schedule});
RoleScheduleDialogState copyWith({List<RoleScheduleEntity>? schedule}) {
return RoleScheduleDialogState(
schedule: schedule ?? this.schedule,
);
}
}

View File

@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/common/int_extensions.dart';
import 'package:krow/core/entity/role_schedule_entity.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/kw_time_slot.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/create_event/presentation/role_schedule_dialog/bloc/role_schedule_dialog_bloc.dart';
class RecurringScheduleDialog extends StatefulWidget {
const RecurringScheduleDialog({
super.key,
});
@override
State<RecurringScheduleDialog> createState() =>
_RecurringScheduleDialogState();
static Future<List<RoleScheduleEntity>?> show(
BuildContext context, List<RoleScheduleEntity>? schedule) {
return showDialog(
context: context,
builder: (BuildContext dialogContext) {
return BlocProvider(
create: (context) => RoleScheduleDialogBloc(schedule ?? []),
child: const RecurringScheduleDialog(),
);
},
);
}
}
class _RecurringScheduleDialogState extends State<RecurringScheduleDialog> {
@override
Widget build(BuildContext context) {
return BlocBuilder<RoleScheduleDialogBloc, RoleScheduleDialogState>(
builder: (context, state) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: KwBoxDecorations.white24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
const Gap(24),
_buildDayTabs(state.schedule),
const Gap(24),
...state.schedule.map((e) => _buildTimeSlots(e)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: KwButton.primary(
label: 'Save Slots',
onPressed: () {
Navigator.of(context).pop(state.schedule);
},
),
),
const Gap(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: KwButton.outlinedPrimary(
label: 'Cancel',
onPressed: () {
Navigator.of(context).pop();
},
),
),
],
),
),
);
},
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Event Schedule',
style: AppTextStyles.bodyLargeMed,
),
GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Assets.images.icons.x.svg(),
),
],
),
const Gap(8),
Padding(
padding: const EdgeInsets.only(right: 24),
child: Text(
'Select the days and time slots that you want this schedule to recur on',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
)
],
),
);
}
_buildDayTabs(List<RoleScheduleEntity> schedule) {
return SingleChildScrollView(
padding: const EdgeInsets.only(left: 24, right: 20),
scrollDirection: Axis.horizontal,
child: Row(
children: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
.indexed
.map((e) {
var (index, item) = e;
var selected = schedule.any((element) => element.dayIndex == index);
return GestureDetector(
onTap: () {
BlocProvider.of<RoleScheduleDialogBloc>(context)
.add(TapOnScheduleDayEvent(index));
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(right: 4),
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(23),
color: selected ? AppColors.bgColorDark : Colors.transparent,
border: Border.all(
color: selected ? AppColors.bgColorDark : AppColors.grayStroke,
),
),
child: Center(
child: Text(
item,
style: AppTextStyles.bodyMediumReg.copyWith(
color: selected ? AppColors.grayWhite : AppColors.blackGray),
)),
),
);
}).toList()),
);
}
Widget _buildTimeSlots(RoleScheduleEntity e) {
return Padding(
padding: const EdgeInsets.only(left: 24, right: 24, bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RichText(
text: TextSpan(
text: e.dayIndex.getWeekdayId(),
style: AppTextStyles.bodyMediumReg.copyWith(height: 1),
children: [
TextSpan(
text: ' Time Slot',
style: AppTextStyles.bodyTinyReg
.copyWith(color: AppColors.blackGray, height: 1),
)
],
),
),
const Gap(8),
_timeRow(context, e),
buildScheduleButton(),
],
),
);
}
_timeRow(context, RoleScheduleEntity entity) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: KwTimeSlotInput(
key: ValueKey('dialog_start_time_key_${entity.dayIndex}'),
label: 'Start Time',
initialValue: entity.startTime,
onChange: (value) {
BlocProvider.of<RoleScheduleDialogBloc>(context)
.add(SetRoleScheduleStartTimeEvent(entity, value));
},
),
),
const Gap(8),
Expanded(
child: KwTimeSlotInput(
key: ValueKey('dialog_start_time_key_${entity.dayIndex}'),
label: 'End Time',
initialValue: entity.endTime,
onChange: (value) {
BlocProvider.of<RoleScheduleDialogBloc>(context)
.add(SetRoleScheduleEndTimeEvent(entity, value));
},
),
),
],
);
}
GestureDetector buildScheduleButton() {
return GestureDetector(
onTap: () {},
child: Container(
margin: const EdgeInsets.only(top: 12),
height: 20,
child: Row(
children: [
Assets.images.icons.copy.svg(),
const Gap(8),
Text(
'Copy Time to All',
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.primaryBlue),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/common/int_extensions.dart';
import 'package:krow/core/entity/role_schedule_entity.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/create_event/presentation/create_role_section/bloc/create_role_bloc.dart';
import 'package:krow/features/create_event/presentation/role_schedule_dialog/recurring_schedule_dialog.dart';
class RecurringScheduleWidget extends StatelessWidget {
const RecurringScheduleWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateRoleBloc, CreateRoleState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (state.entity.schedule != null &&
(state.entity.schedule?.isNotEmpty ?? false)) ...[
const Gap(12),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: const Text(
'Schedule',
style: AppTextStyles.bodyMediumReg,
),
),
...state.entity.schedule!.map((e) => _buildScheduleItem(e)),
const Gap(8),
],
buildScheduleButton(context, state),
],
);
},
);
}
GestureDetector buildScheduleButton(
BuildContext context, CreateRoleState state) {
return GestureDetector(
onTap: () async {
var result =
await RecurringScheduleDialog.show(context, state.entity.schedule);
if (result != null) {
context
.read<CreateRoleBloc>()
.add(CreateRoleSetScheduleEvent(result));
}
},
child: Container(
margin: const EdgeInsets.only(top: 12),
height: 20,
child: Row(
children: [
Assets.images.icons.calendarEdit.svg(),
const Gap(8),
Text(
'Set Work Days',
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.primaryBlue),
),
],
),
),
);
}
Widget _buildScheduleItem(RoleScheduleEntity e) {
return Container(
padding: const EdgeInsets.only(left: 16, right: 16),
margin: const EdgeInsets.only(top: 8),
height: 46,
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(23),
border: Border.all(color: AppColors.grayStroke, width: 1),
),
child: Text(e.dayIndex.getWeekdayId()),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
class AddInfoInputWidget extends StatefulWidget {
const AddInfoInputWidget({super.key});
@override
State<AddInfoInputWidget> createState() => _AddInfoInputWidgetState();
}
class _AddInfoInputWidgetState extends State<AddInfoInputWidget> {
final TextEditingController _addInfoController = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocConsumer<CreateEventBloc, CreateEventState>(
listener: (context, state) {
if (state.entity.additionalInfo != _addInfoController.text) {
_addInfoController.text = state.entity.additionalInfo??'';
}
},
buildWhen: (previous, current) {
return previous.entity.additionalInfo != current.entity.additionalInfo;
},
builder: (context, state) {
return KwTextInput(
controller: _addInfoController,
onChanged: (value) {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventAddInfoChange(value));
},
maxLength: 300,
showCounter: true,
minHeight: 144,
hintText: 'Enter your main text here...',
title: 'Additional Information',
);
},
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/cupertino.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/features/create_event/domain/bloc/create_event_bloc.dart';
class AddonsSectionWidget extends StatelessWidget {
const AddonsSectionWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateEventBloc, CreateEventState>(
builder: (context, state) {
var allAddons = state.addons;
var selectedAddons = state.entity.addons;
if(context.read<CreateEventBloc>().state.addons.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
const Divider(
height: 0,
color: AppColors.grayStroke,
),
for (var addon in allAddons)
_addonStrokeItem(
title: addon.name ?? '',
enabled: selectedAddons
?.any((selected) => selected.id == addon.id) ??
false,
onTap: () {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventToggleAddon(addon));
},
),
const Gap(12),
const Divider(
height: 0,
color: AppColors.grayStroke,
),
],
);
},
);
}
_addonStrokeItem(
{required String title,
required bool enabled,
required VoidCallback onTap}) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Expanded(
child: Text(
title,
style: AppTextStyles.bodyMediumMed,
)),
CupertinoSwitch(
value: enabled,
onChanged: (_) {
onTap();
},
activeTrackColor: AppColors.bgColorDark,
)
],
),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/hub_model.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_dropdown.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
import 'package:krow/features/create_event/presentation/event_date_section/event_date_input_widget.dart';
import '../../../../core/entity/event_entity.dart';
class CreateEventDetailsCardWidget extends StatefulWidget {
const CreateEventDetailsCardWidget({super.key});
@override
State<CreateEventDetailsCardWidget> createState() =>
_CreateEventDetailsCardWidgetState();
}
class _CreateEventDetailsCardWidgetState
extends State<CreateEventDetailsCardWidget>
with AutomaticKeepAliveClientMixin {
TextEditingController poNumberController = TextEditingController();
// TextEditingController contractNumberController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return BlocConsumer<CreateEventBloc, CreateEventState>(
listener: (context, state) {
if (state.entity.name != _nameController.text) {
_nameController.text = state.entity.name;
}
// if (state.entity.contractNumber != null &&
// state.entity.contractNumber != contractNumberController.text) {
// contractNumberController.text = state.entity.contractNumber!;
// }
//
if (state.entity.poNumber != null &&
state.entity.poNumber != poNumberController.text) {
poNumberController.text = state.entity.poNumber!;
}
},
builder: (context, state) {
return AnimatedContainer(
duration: Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
decoration: KwBoxDecorations.white12.copyWith(
border: state.validationState != null
? Border.all(color: AppColors.statusError, width: 1)
: null),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Event Details',
style: AppTextStyles.bodyMediumMed,
),
const Gap(12),
KwTextInput(
controller: _nameController,
title: 'Event Name',
hintText: 'Enter event name',
onChanged: (value) {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventNameChange(value));
},
),
IgnorePointer(
ignoring: ![EventStatus.draft, EventStatus.pending].contains(state.entity.status) && state.entity.status!=null,
child: EventDateInputWidget(state.entity)),
const Gap(8),
_hubDropdown(state.hubs, state),
const Gap(8),
// _contractDropdown(context, state),
// if (state.entity.contractType == EventContractType.contract)
// _buildContractInput(),
// if (state.entity.contractType == EventContractType.purchaseOrder)
_buildPurchaseInput(),
],
),
);
},
);
}
Column _hubDropdown(List<HubModel> hubs, CreateEventState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
'Location',
style:
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
),
),
const Gap(4),
KwDropdown(
horizontalPadding: 28,
hintText: 'Hub name',
selectedItem: state.entity.hub != null
? KwDropDownItem(
data: state.entity.hub, title: state.entity.hub?.name ?? '')
: null,
items: hubs
.map((e) => KwDropDownItem(data: e, title: e.name ?? ''))
.toList(),
onSelected: (item) {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventChangeHub(item!));
}),
],
);
}
// Column _contractDropdown(BuildContext context, CreateEventState state) {
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Padding(
// padding: const EdgeInsets.only(left: 16),
// child: Text(
// 'Payment type',
// style:
// AppTextStyles.bodyTinyReg.copyWith(color: AppColors.blackGray),
// ),
// ),
// const Gap(4),
// KwDropdown(
// horizontalPadding: 28,
// hintText: 'Direct',
// selectedItem: KwDropDownItem(
// data: state.entity.contractType,
// title: state.entity.contractType.formattedName),
// items: const [
// KwDropDownItem(data: EventContractType.direct, title: 'Direct'),
// KwDropDownItem(
// data: EventContractType.purchaseOrder,
// title: 'Purchase Order'),
// KwDropDownItem(
// data: EventContractType.contract, title: 'Contract'),
// ],
// onSelected: (item) {
// BlocProvider.of<CreateEventBloc>(context)
// .add(CreateEventChangeContractType(item));
// }),
// ],
// );
// }
//
// _buildContractInput() {
// return Padding(
// padding: const EdgeInsets.only(top:8.0),
// child: KwTextInput(
// controller: contractNumberController,
// onChanged: (value) {
// BlocProvider.of<CreateEventBloc>(context)
// .add(CreateEventChangeContractNumber(value));
// },
// title: 'Contract number',
// hintText: '#00000'),
// );
// }
//
_buildPurchaseInput() {
return Padding(
padding: const EdgeInsets.only(top:8.0),
child: KwTextInput(
controller: poNumberController,
onChanged: (value) {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventChangePoNumber(value));
},
title: 'PO Reference number',
hintText: 'PO Reference number'),
);
}
}
//
// extension on EventContractType? {
// String get formattedName {
// return switch (this) {
// EventContractType.direct => 'Direct',
// EventContractType.contract => 'Contract',
// EventContractType.purchaseOrder => 'Purchase Order',
// null => 'null'
// };
// }
// }

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/tag_model.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/create_event/domain/bloc/create_event_bloc.dart';
class CreateEventTagsCard extends StatelessWidget {
const CreateEventTagsCard({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateEventBloc, CreateEventState>(
buildWhen: (previous, current) =>
previous.tags != current.tags ||
previous.entity.tags != current.entity.tags,
builder: (context, state) {
if(state.tags.isEmpty) return const SizedBox();
return Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
decoration: KwBoxDecorations.white12,
child: _buildChips(context, state.tags, state.entity.tags ?? []),
);
},
);
}
Widget _buildChips(BuildContext context, List<TagModel> allTags,
List<TagModel> selectedTags) {
return Padding(
padding: const EdgeInsets.only(left: 16, right: 12),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: allTags
.map((e) =>
_buildTag(context, e, selectedTags.any((t) => e.id == t.id)))
.toList(),
),
);
}
Widget _buildTag(BuildContext context, TagModel tag, bool selected) {
const duration = Duration(milliseconds: 150);
return GestureDetector(
onTap: () {
BlocProvider.of<CreateEventBloc>(context)
.add(CreateEventTagSelected(tag));
},
child: AnimatedContainer(
duration: duration,
padding: const EdgeInsets.all(8),
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected ? AppColors.blackBlack : AppColors.grayTintStroke,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: duration,
height: 28,
width: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? AppColors.blackBlack : AppColors.tintGray,
),
child: Center(
child: getImageById(tag.id).svg(
width: 12.0,
height: 12.0,
colorFilter: ColorFilter.mode(
selected ? AppColors.grayWhite : AppColors.blackBlack,
BlendMode.srcIn)),
),
),
const Gap(8),
Text(
tag.name,
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.bgColorDark),
),
const Gap(8),
AnimatedContainer(
duration: duration,
height: 16,
width: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected
? AppColors.blackBlack
: AppColors.grayTintStroke,
width: 1,
),
),
child: Center(
child: AnimatedContainer(
duration: duration,
height: 6,
width: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
selected ? AppColors.blackBlack : Colors.transparent,
)),
),
),
],
),
),
);
}
getImageById(String id) {
switch (id) {
case '1':
return Assets.images.icons.tags.award;
case '2':
return Assets.images.icons.tags.briefcase;
case '3':
return Assets.images.icons.tags.flash;
}
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/cupertino.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
class CreateEventTitleWidget extends StatelessWidget {
const CreateEventTitleWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(8),
const Text('Create Your Event', style: AppTextStyles.headingH1),
const Gap(8),
Text(
'Bring your vision to life! Share details, set the stage, and connect with your audience—your event starts here.',
style:
AppTextStyles.bodyMediumReg.copyWith(color: AppColors.blackGray),
),
const Gap(24),
],
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/create_event/domain/bloc/create_event_bloc.dart';
class TotalCostRowWidget extends StatelessWidget {
TotalCostRowWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateEventBloc, CreateEventState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Approximate Total Costs',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
),
ValueListenableBuilder(
valueListenable: state.entity.totalCost,
builder: (context, value, child) {
return Text(
'\$${value.toStringAsFixed(2)}',
style: AppTextStyles.bodyMediumMed,
);
},
)
],
);
},
);
}
}

View File

@@ -0,0 +1,114 @@
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/event/event_model.dart';
import 'package:krow/core/data/models/pagination_wrapper/pagination_wrapper.dart';
import 'package:krow/features/events/data/events_gql.dart';
@Injectable()
class EventsApiProvider {
final ApiClient _client;
EventsApiProvider({required ApiClient client}) : _client = client;
Future<PaginationWrapper?> fetchEvents(String status, {String? after}) async {
final QueryResult result = await _client.query(
schema: getEventsQuery,
body: {'status': status, 'first': 30, 'after': after});
if (result.hasException) {
throw parseBackendError(result.exception);
}
if (result.data == null || result.data!['client_events'] == null) {
return null;
}
return PaginationWrapper<EventModel?>.fromJson(
result.data!['client_events'], (json) {
try {
return EventModel.fromJson(json);
}catch (e) {
print(e);
return null;
}
});
}
Future<EventModel?> fetchEventById(String id) async {
final QueryResult result = await _client.query(schema: getEventById, body: {
'id': id,
});
if (result.hasException) {
throw parseBackendError(result.exception);
}
if (result.data == null || result.data!['client_event'] == null) {
return null;
}
return EventModel.fromJson(result.data!['client_event']);
}
Future<void> cancelClientEvent(String id) async {
final QueryResult result = await _client.mutate(
schema: cancelClientEventMutation,
body: {'event_id': id},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> completeClientEvent(String id, {String? comment}) async {
final QueryResult result = await _client.mutate(
schema: completeClientEventMutation,
body: {'event_id': id, if(comment!=null)'comment': comment.trim()},
);
if (result.hasException) {
print(result.exception.toString());
throw parseBackendError(result.exception);
}
}
Future<void> trackClientClockin(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: trackClientClockinMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> trackClientClockout(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: trackClientClockoutMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> notShowedPositionStaff(String positionStaffId) async {
final QueryResult result = await _client.mutate(
schema: notShowedPositionStaffMutation,
body: {'position_staff_id': positionStaffId},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
Future<void> replacePositionStaff(
String positionStaffId, String reason) async {
final QueryResult result = await _client.mutate(
schema: replacePositionStaffMutation,
body: {'position_staff_id': positionStaffId, 'reason': reason.trim()},
);
if (result.hasException) {
throw parseBackendError(result.exception);
}
}
}

View File

@@ -0,0 +1,272 @@
String getEventsQuery = '''
query GetEvents (\$status: EventStatus!, \$first: Int!, \$after: String) {
client_events(status: \$status, first: \$first, after: \$after) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
total
count
currentPage
lastPage
}
edges {
...node
cursor
}
}
}
$nodeFragment
''';
var getEventById = '''
query GetEventById(\$id: ID!) {
client_event(id: \$id) {
$_eventFields
}
}
$fragments
''';
var nodeFragment = '''
fragment node on EventEdge {
node {
$_eventsListFields
}
}
''';
const String cancelClientEventMutation = r'''
mutation cancel_client_event($event_id: ID!) {
cancel_client_event(event_id: $event_id) {
id
}
}
''';
const String completeClientEventMutation = r'''
mutation complete_client_event($event_id: ID!, $comment: String) {
complete_client_event(event_id: $event_id, comment: $comment) {
id
}
}
''';
const String trackClientClockinMutation = r'''
mutation track_client_clockin($position_staff_id: ID!) {
track_client_clockin(position_staff_id: $position_staff_id) {
id
}
}
''';
const String trackClientClockoutMutation = r'''
mutation track_client_clockout($position_staff_id: ID!) {
track_client_clockout(position_staff_id: $position_staff_id) {
id
}
}
''';
const String notShowedPositionStaffMutation = r'''
mutation not_showed_position_staff($position_staff_id: ID!) {
not_showed_position_staff(position_staff_id: $position_staff_id) {
id
}
}
''';
const String replacePositionStaffMutation = r'''
mutation replace_position_staff($position_staff_id: ID!, $reason: String!) {
replace_position_staff(position_staff_id: $position_staff_id, reason: $reason) {
id
}
}
''';
String _eventsListFields = '''
id
business {
id
name
registration
avatar
}
hub {
id
name
address
}
name
status
date
start_time
end_time
purchase_order
contract_type
schedule_type
''';
String _eventFields = '''
id
business {
id
name
registration
avatar
...addons
}
hub {
id
name
address
}
name
status
date
start_time
end_time
purchase_order
contract_type
schedule_type
additional_info
addons {
id
name
}
tags {
id
name
slug
}
...shifts
''';
String fragments = '''fragment addons on Business {
addons {
id
name
}
}
fragment shifts on Event {
shifts {
id
name
address
...full_address
contacts {
id
first_name
last_name
title
...auth_info
}
positions {
id
count
start_time
end_time
rate
break
...business_skill
...staff
department {
id
name
}
}
}
}
fragment auth_info on BusinessMember {
auth_info {
email
phone
}
}
fragment business_skill on EventShiftPosition {
business_skill {
id
skill {
id
name
slug
price
}
price
is_active
}
}
fragment full_address on EventShift {
full_address {
street_number
zip_code
latitude
longitude
formatted_address
street
region
city
country
}
}
fragment staff on EventShiftPosition {
staff {
id
first_name
last_name
middle_name
email
phone
avatar
pivot {
id
status
start_at
end_at
clock_in
clock_out
...staff_position
...cancel_reason
rating {
id
rating
}
}
}
}
fragment cancel_reason on EventShiftPositionStaff {
cancel_reason {
type
reason
details
}
}
fragment staff_position on EventShiftPositionStaff {
position {
id
count
start_time
end_time
rate
break
business_skill {
id
price
skill{
name
}
}
}
}''';

View File

@@ -0,0 +1,137 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:krow/features/events/data/events_api_provider.dart';
import 'package:krow/features/events/domain/events_repository.dart';
@Singleton(as: EventsRepository)
class EventsRepositoryImpl extends EventsRepository {
final EventsApiProvider _apiProvider;
StreamController<EventStatus>? _statusController;
EventsRepositoryImpl({required EventsApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Stream<EventStatus> get statusStream {
_statusController ??= StreamController<EventStatus>.broadcast();
return _statusController!.stream;
}
@override
Future<List<EventEntity>> getEvents(
{String? lastItemId, required EventStatus statusFilter}) async {
try {
var paginationWrapper = await _apiProvider.fetchEvents(
statusFilterToGqlString(statusFilter),
after: lastItemId);
return paginationWrapper?.edges
.map((e) => EventEntity.fromEventDto(
e.node,
cursor: paginationWrapper.pageInfo.hasNextPage
? e.cursor
: null,
))
.toList() ??
[];
} catch (e) {
rethrow;
}
}
@override
void dispose() {
_statusController?.close();
}
statusFilterToGqlString(EventStatus statusFilter) {
return statusFilter.name;
}
@override
Future<void> cancelClientEvent(String id, EventStatus? status) async {
try {
await _apiProvider.cancelClientEvent(id);
if (!(_statusController?.isClosed ?? true)) {
if (status != null) _statusController?.add(status);
}
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> completeClientEvent(String id, {String? comment}) async {
try {
await _apiProvider.completeClientEvent(id, comment: comment);
if (!(_statusController?.isClosed ?? true)) {
_statusController?.add(EventStatus.finished);
_statusController?.add(EventStatus.completed);
}
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> trackClientClockin(String positionStaffId) async {
try {
await _apiProvider.trackClientClockin(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> trackClientClockout(String positionStaffId) async {
try {
await _apiProvider.trackClientClockout(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> notShowedPositionStaff(String positionStaffId) async {
try {
await _apiProvider.notShowedPositionStaff(positionStaffId);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
Future<void> replacePositionStaff(
String positionStaffId, String reason) async {
try {
await _apiProvider.replacePositionStaff(positionStaffId, reason);
} catch (exception) {
debugPrint(exception.toString());
rethrow;
}
}
@override
void refreshEvents(EventStatus status) {
if (!(_statusController?.isClosed ?? true)) {
_statusController?.add(status);
}
}
@override
Future<EventEntity?> getEventById(String id) async {
return await _apiProvider.fetchEventById(id).then((event) {
if (event != null) {
return EventEntity.fromEventDto(event);
}
return null;
});
}
}

View File

@@ -0,0 +1,381 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:krow/core/application/clients/api/api_exception.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/models/staff/pivot.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:krow/core/entity/position_entity.dart';
import 'package:krow/core/entity/shift_entity.dart';
import 'package:krow/core/entity/staff_contact_entity.dart';
import 'package:krow/core/sevices/create_event_service/create_event_service.dart';
import 'package:krow/core/sevices/geofencin_service.dart';
import 'package:krow/features/events/domain/events_repository.dart';
import 'package:meta/meta.dart';
part 'event_details_event.dart';
part 'event_details_state.dart';
class EventDetailsBloc extends Bloc<EventDetailsEvent, EventDetailsState> {
final EventsRepository _repository;
Timer? _timer;
var disabledPolling = false;
EventDetailsBloc(EventEntity event, this._repository, isPreview)
: super(EventDetailsState(event: event, isPreview: isPreview)) {
on<EventDetailsInitialEvent>(_onInit);
on<EventDetailsUpdateEvent>(_onUpdateEntity);
on<OnShiftHeaderTapEvent>(_onShiftHeaderTap);
on<OnRoleHeaderTapEvent>(_onRoleHeaderTap);
on<OnAssignedStaffHeaderTapEvent>(_onAssignedStaffHeaderTap);
on<CompleteEventEvent>(_onCompleteEvent);
on<CancelClientEvent>(_onCancelClientEvent);
on<TrackClientClockin>(_onTrackClientClockin);
on<TrackClientClockout>(_onTrackClientClockout);
on<NotShowedPositionStaffEvent>(_onNotShowedPositionStaff);
on<ReplacePositionStaffEvent>(_onReplacePositionStaff);
on<CreateEventPostEvent>(_createEvent);
on<DetailsDeleteDraftEvent>(_onDetailsDeleteDraftEvent);
on<DetailsPublishEvent>(_onDetailsPublishEvent);
on<GeofencingEvent>(_onGeofencingEvent);
on<RefreshEventDetailsEvent>(_onRefreshEventDetailsEvent);
on<DisablePollingEvent>(_onDisablePollingEvent);
on<EnablePollingEvent>(_onEnablePollingEvent);
}
@override
Future<void> close() {
_timer?.cancel();
return super.close();
}
FutureOr<void> _onShiftHeaderTap(OnShiftHeaderTapEvent event, emit) {
emit(state.copyWith(
shifts: state.shifts
.map((e) =>
e.copyWith(isExpanded: e == event.shiftState && !e.isExpanded))
.toList()));
}
FutureOr<void> _onRoleHeaderTap(OnRoleHeaderTapEvent event, emit) {
emit(state.copyWith(
shifts: state.shifts
.map((e) => e.copyWith(
positions: e.positions
.map((r) => r.copyWith(
isExpanded:
r.position.id == event.roleState.position.id &&
!r.isExpanded))
.toList()))
.toList()));
}
FutureOr<void> _onAssignedStaffHeaderTap(
OnAssignedStaffHeaderTapEvent event, emit) {
emit(state.copyWith(
shifts: state.shifts
.map((e) => e.copyWith(
positions: e.positions
.map((r) => r.copyWith(
isStaffExpanded:
r.position.id == event.roleState.position.id &&
!r.isStaffExpanded))
.toList()))
.toList()));
}
FutureOr<void> _onCompleteEvent(CompleteEventEvent event, emit) async {
try {
emit(state.copyWith(inLoading: true));
await _repository.completeClientEvent(state.event.id,
comment: event.comment);
emit(state.copyWith(
event: state.event.copyWith(status: EventStatus.completed)));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
return;
}
}
emit(state.copyWith(inLoading: false));
}
FutureOr<void> _onCancelClientEvent(CancelClientEvent event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await _repository.cancelClientEvent(state.event.id, state.event.status);
emit(state.copyWith(
event: state.event.copyWith(status: EventStatus.canceled)));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
}
}
emit(state.copyWith(inLoading: false));
}
FutureOr<void> _onInit(EventDetailsInitialEvent event, emit) {
getIt<CreateEventService>().eventsRepository = _repository;
emit(state.copyWith(
inLoading: true,
shifts: state.event.shifts
?.map((shift) => ShiftState(
shift: shift,
positions: shift.positions
.map((position) => PositionState(position: position))
.toList()))
.toList() ??
[],
));
add(RefreshEventDetailsEvent());
if (!state.isPreview) {
startPoling();
}else{
emit(state.copyWith(inLoading: false));
}
}
FutureOr<void> _onTrackClientClockin(TrackClientClockin event, emit) async {
emit(state.copyWith(inLoading: true));
if (!(await checkGeofancing(event.staffContact))) {
emit(state.copyWith(inLoading: false));
return;
}
try {
await _repository.trackClientClockin(event.staffContact.id);
event.staffContact.status = PivotStatus.ongoing;
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
}
}
emit(state.copyWith(inLoading: false));
}
FutureOr<void> _onTrackClientClockout(TrackClientClockout event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await _repository.trackClientClockout(event.staffContact.id);
event.staffContact.status = PivotStatus.confirmed;
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
}
}
emit(state.copyWith(inLoading: false));
}
FutureOr<void> _onNotShowedPositionStaff(
NotShowedPositionStaffEvent event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await _repository.notShowedPositionStaff(event.positionStaffId);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
}
}
emit(state.copyWith(inLoading: false));
}
FutureOr<void> _onReplacePositionStaff(
ReplacePositionStaffEvent event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await _repository.replacePositionStaff(
event.positionStaffId, event.reason);
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(showErrorPopup: e.message));
}else{
emit(state.copyWith(
showErrorPopup: 'Something went wrong'));
}
}
emit(state.copyWith(inLoading: false));
}
void _createEvent(
CreateEventPostEvent event, Emitter<EventDetailsState> emit) async {
emit(state.copyWith(inLoading: true));
try {
if (state.event.id.isEmpty) {
await getIt<CreateEventService>().createEventService(
state.event,
);
} else {
await getIt<CreateEventService>().updateEvent(
state.event,
);
}
// emit(state.copyWith(inLoading: false, ));
emit(state.copyWith(inLoading: false, needDeepPop: true));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(inLoading: false, showErrorPopup: e.message));
return;
} else {
print(e);
emit(state.copyWith(
inLoading: false, showErrorPopup: 'Something went wrong'));
return;
}
}
emit(state.copyWith(inLoading: false));
}
void _onDetailsDeleteDraftEvent(DetailsDeleteDraftEvent event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<CreateEventService>().deleteDraft(
state.event,
);
emit(state.copyWith(inLoading: false, needDeepPop: true));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(inLoading: false, showErrorPopup: e.message));
return;
} else {
emit(state.copyWith(
inLoading: false, showErrorPopup: 'Something went wrong'));
}
}
emit(state.copyWith(inLoading: false));
}
void _onDetailsPublishEvent(DetailsPublishEvent event, emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<CreateEventService>().publishEvent(
state.event,
);
emit(state.copyWith(inLoading: false, needDeepPop: true));
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(inLoading: false, showErrorPopup: e.message));
return;
} else {
print(e);
emit(state.copyWith(
inLoading: false, showErrorPopup: 'Something went wrong'));
}
}
emit(state.copyWith(inLoading: false));
}
void startPoling() {
_timer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if(state.isPreview) return;
add(RefreshEventDetailsEvent());
});
}
void _onRefreshEventDetailsEvent(
RefreshEventDetailsEvent event, Emitter<EventDetailsState> emit) async {
if(state.isPreview) return;
try {
await _repository.getEventById(state.event.id).then((event) {
if (event != null) {
add(EventDetailsUpdateEvent(event));
}
});
} catch (e) {
print(e);
}
}
void _onUpdateEntity(EventDetailsUpdateEvent event, emit) {
if(disabledPolling) return;
emit(
state.copyWith(
inLoading: false,
event: event.event,
shifts: event.event.shifts?.map(
(shift) {
var oldShift = state.shifts
.firstWhereOrNull((e) => e.shift.id == shift.id);
return ShiftState(
isExpanded: oldShift?.isExpanded ?? false,
shift: shift,
positions: shift.positions.map(
(position) {
var oldPosition = oldShift?.positions.firstWhereOrNull(
(e) => e.position.id == position.id);
return PositionState(
position: position,
isExpanded: oldPosition?.isExpanded ?? false,
isStaffExpanded:
oldPosition?.isStaffExpanded ?? false);
},
).toList());
},
).toList() ??
[],
),
);
}
Future<bool> checkGeofancing(StaffContact staffContact) async {
var permissionResult =
await GeofencingService().requestGeolocationPermission();
if (permissionResult == GeolocationStatus.enabled) {
var result = await GeofencingService().isInRangeCheck(
pointLatitude:
staffContact.parentPosition?.parentShift?.fullAddress?.latitude ??
0,
pointLongitude:
staffContact.parentPosition?.parentShift?.fullAddress?.longitude ??
0,
);
if (result) {
return true;
} else {
add(GeofencingEvent(GeofencingDialogState.tooFar));
}
} else if (permissionResult == GeolocationStatus.disabled) {
add(GeofencingEvent(GeofencingDialogState.locationDisabled));
} else if (permissionResult == GeolocationStatus.prohibited) {
add(GeofencingEvent(GeofencingDialogState.goToSettings));
} else if (permissionResult == GeolocationStatus.denied) {
add(GeofencingEvent(GeofencingDialogState.permissionDenied));
}
return false;
}
void _onGeofencingEvent(GeofencingEvent event, emit) {
emit(state.copyWith(geofencingDialogState: event.dialogState));
if (event.dialogState != GeofencingDialogState.none) {
add(GeofencingEvent(GeofencingDialogState.none));
}
}
void _onDisablePollingEvent(DisablePollingEvent event, emit) {
disabledPolling = true;
_timer?.cancel();
_timer = null;
}
void _onEnablePollingEvent(EnablePollingEvent event, emit) {
disabledPolling = false;
if (_timer == null) {
startPoling();
}
}
@override
void onEvent(EventDetailsEvent event) {
print(event);
super.onEvent(event);
}
}

View File

@@ -0,0 +1,105 @@
part of 'event_details_bloc.dart';
@immutable
abstract class EventDetailsEvent {}
class EventDetailsInitialEvent extends EventDetailsEvent {
EventDetailsInitialEvent();
}
class EventDetailsUpdateEvent extends EventDetailsEvent {
final EventEntity event;
EventDetailsUpdateEvent(this.event);
}
class OnShiftHeaderTapEvent extends EventDetailsEvent {
final ShiftState shiftState;
OnShiftHeaderTapEvent(this.shiftState);
}
class OnRoleHeaderTapEvent extends EventDetailsEvent {
final PositionState roleState;
OnRoleHeaderTapEvent(this.roleState);
}
class OnAssignedStaffHeaderTapEvent extends EventDetailsEvent {
final PositionState roleState;
OnAssignedStaffHeaderTapEvent(this.roleState);
}
class CompleteEventEvent extends EventDetailsEvent {
final String? comment;
CompleteEventEvent({this.comment});
}
class CancelClientEvent extends EventDetailsEvent {
CancelClientEvent();
}
class CompleteClientEvent extends EventDetailsEvent {
final String id;
final String? comment;
CompleteClientEvent(this.id, {this.comment});
}
class TrackClientClockin extends EventDetailsEvent {
final StaffContact staffContact;
TrackClientClockin(this.staffContact);
}
class TrackClientClockout extends EventDetailsEvent {
final StaffContact staffContact;
TrackClientClockout(this.staffContact);
}
class NotShowedPositionStaffEvent extends EventDetailsEvent {
final String positionStaffId;
NotShowedPositionStaffEvent(this.positionStaffId);
}
class ReplacePositionStaffEvent extends EventDetailsEvent {
final String positionStaffId;
final String reason;
ReplacePositionStaffEvent(this.positionStaffId, this.reason);
}
class CreateEventPostEvent extends EventDetailsEvent {
CreateEventPostEvent();
}
class DetailsDeleteDraftEvent extends EventDetailsEvent {
DetailsDeleteDraftEvent();
}
class DetailsPublishEvent extends EventDetailsEvent {
DetailsPublishEvent();
}
class GeofencingEvent extends EventDetailsEvent {
final GeofencingDialogState dialogState;
GeofencingEvent(this.dialogState);
}
class RefreshEventDetailsEvent extends EventDetailsEvent {
RefreshEventDetailsEvent();
}
class DisablePollingEvent extends EventDetailsEvent {
DisablePollingEvent();
}
class EnablePollingEvent extends EventDetailsEvent {
EnablePollingEvent();
}

View File

@@ -0,0 +1,99 @@
part of 'event_details_bloc.dart';
enum GeofencingDialogState {
none,
tooFar,
locationDisabled,
goToSettings,
permissionDenied,
}
@immutable
class EventDetailsState {
final EventEntity event;
final List<ShiftState> shifts;
final bool inLoading;
final bool isPreview;
final String? showErrorPopup;
final bool needDeepPop;
final GeofencingDialogState geofencingDialogState;
const EventDetailsState({
required this.event,
this.shifts = const [],
this.inLoading = false,
this.isPreview = false,
this.needDeepPop = false,
this.showErrorPopup,
this.geofencingDialogState = GeofencingDialogState.none,
});
EventDetailsState copyWith({
EventEntity? event,
List<ShiftState>? shifts,
bool? inLoading,
bool? isPreview,
bool? needDeepPop,
String? showErrorPopup,
GeofencingDialogState? geofencingDialogState,
}) {
return EventDetailsState(
event: event ?? this.event,
shifts: shifts ?? this.shifts,
inLoading: inLoading ?? this.inLoading,
isPreview: isPreview ?? this.isPreview,
needDeepPop: needDeepPop ?? this.needDeepPop,
showErrorPopup: showErrorPopup,
geofencingDialogState:
geofencingDialogState ?? GeofencingDialogState.none,
);
}
}
class ShiftState {
final bool isExpanded;
final ShiftEntity shift;
final List<PositionState> positions;
ShiftState({
this.isExpanded = false,
required this.shift,
this.positions = const [],
});
ShiftState copyWith({
bool? isExpanded,
ShiftEntity? shift,
List<PositionState>? positions,
DateTime? date,
}) {
return ShiftState(
isExpanded: isExpanded ?? this.isExpanded,
shift: shift ?? this.shift,
positions: positions ?? this.positions,
);
}
}
class PositionState {
final PositionEntity position;
final bool isExpanded;
final bool isStaffExpanded;
PositionState(
{required this.position,
this.isExpanded = false,
this.isStaffExpanded = false});
PositionState copyWith({
PositionEntity? position,
bool? isExpanded,
bool? isStaffExpanded,
}) {
return PositionState(
position: position ?? this.position,
isExpanded: isExpanded ?? this.isExpanded,
isStaffExpanded: isStaffExpanded ?? this.isStaffExpanded,
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/entity/event_entity.dart';
import 'package:krow/features/events/domain/blocs/events_list_bloc/events_event.dart';
import 'package:krow/features/events/domain/blocs/events_list_bloc/events_state.dart';
import 'package:krow/features/events/domain/events_repository.dart';
class EventsBloc extends Bloc<EventsEvent, EventsState> {
EventsBloc()
: super(const EventsState(tabs: {
0: {
0: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.pending),
1: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.assigned),
2: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.confirmed),
},
1: {
0: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.active),
1: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.finished),
},
2: {
0: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.completed),
1: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.closed),
2: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.canceled),
},
3: {
0: EventTabState(
items: [],
inLoading: false,
hasMoreItems: true,
status: EventStatus.draft),
},
})) {
on<EventsInitialEvent>(_onInitial);
on<EventsTabChangedEvent>(_onTabChanged);
on<EventsSubTabChangedEvent>(_onSubTabChanged);
on<LoadTabEventEvent>(_onLoadTabItems);
on<LoadMoreEventEvent>(_onLoadMoreTabItems);
getIt<EventsRepository>().statusStream.listen((event) {
(int, int)? pair = findTabIndexForStatus(state.tabs, event);
if (pair != null) {
add(LoadTabEventEvent(tabIndex: pair.$1, subTabIndex: pair.$2));
}
});
}
Future<void> _onInitial(EventsInitialEvent event, emit) async {
add(const LoadTabEventEvent(tabIndex: 0, subTabIndex: 0));
}
Future<void> _onTabChanged(EventsTabChangedEvent event, emit) async {
emit(state.copyWith(tabIndex: event.tabIndex, subTabIndex: 0));
final currentTabState = state.tabs[event.tabIndex]![0]!;
if (currentTabState.items.isEmpty && !currentTabState.inLoading) {
add(LoadTabEventEvent(tabIndex: event.tabIndex, subTabIndex: 0));
}
}
Future<void> _onSubTabChanged(EventsSubTabChangedEvent event, emit) async {
emit(state.copyWith(subTabIndex: event.subTabIndex));
final currentTabState = state.tabs[state.tabIndex]![event.subTabIndex]!;
if (currentTabState.items.isEmpty && !currentTabState.inLoading) {
add(LoadTabEventEvent(
tabIndex: state.tabIndex, subTabIndex: event.subTabIndex));
}
}
Future<void> _onLoadTabItems(LoadTabEventEvent event, emit) async {
await _fetchEvents(event.tabIndex, event.subTabIndex, null, emit);
}
Future<void> _onLoadMoreTabItems(LoadMoreEventEvent event, emit) async {
final currentTabState = state.tabs[event.tabIndex]![event.subTabIndex]!;
if (!currentTabState.hasMoreItems || currentTabState.inLoading) return;
await _fetchEvents(
event.tabIndex, event.subTabIndex, currentTabState.items, emit);
}
_fetchEvents(int tabIndex, int subTabIndex, List<EventEntity>? previousItems,
emit) async {
if (previousItems != null && previousItems.lastOrNull?.cursor == null) {
return;
}
final currentTabState = state.tabs[tabIndex]![subTabIndex]!;
var newState = state.copyWith(
tabs: {
...state.tabs,
tabIndex: {
...state.tabs[tabIndex]!,
subTabIndex: currentTabState.copyWith(inLoading: true)
},
},
);
emit(newState);
await Future.delayed(const Duration(seconds: 1));
try {
var items = await getIt<EventsRepository>().getEvents(
statusFilter: currentTabState.status,
lastItemId: previousItems?.lastOrNull?.cursor,
);
var newState = state.copyWith(
tabs: {
...state.tabs,
tabIndex: {
...state.tabs[tabIndex]!,
subTabIndex: currentTabState.copyWith(
items: (previousItems ?? [])..addAll(items),
hasMoreItems: items.isNotEmpty,
inLoading: false,
)
},
},
);
emit(newState);
} catch (e) {
debugPrint(e.toString());
emit(state.copyWith(errorMessage: e.toString()));
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: {
...state.tabs[tabIndex]!,
subTabIndex: currentTabState.copyWith(
items: (previousItems ?? []),
hasMoreItems: false,
inLoading: false,
)
},
},
));
}
}
@override
Future<void> close() {
getIt<EventsRepository>().dispose();
return super.close();
}
(int, int)? findTabIndexForStatus(
Map<int, Map<int, EventTabState>> tabs, EventStatus status) {
for (var tabEntry in tabs.entries) {
for (var subTabEntry in tabEntry.value.entries) {
if (subTabEntry.value.status == status) {
return (tabEntry.key, subTabEntry.key);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
sealed class EventsEvent {
const EventsEvent();
}
class EventsInitialEvent extends EventsEvent {
const EventsInitialEvent();
}
class EventsTabChangedEvent extends EventsEvent {
final int tabIndex;
const EventsTabChangedEvent({required this.tabIndex});
}
class EventsSubTabChangedEvent extends EventsEvent {
final int subTabIndex;
const EventsSubTabChangedEvent({required this.subTabIndex});
}
class LoadTabEventEvent extends EventsEvent {
final int tabIndex;
final int subTabIndex;
const LoadTabEventEvent({required this.tabIndex, required this.subTabIndex});
}
class LoadMoreEventEvent extends EventsEvent {
final int tabIndex;
final int subTabIndex;
const LoadMoreEventEvent({required this.tabIndex, required this.subTabIndex});
}

View File

@@ -0,0 +1,61 @@
import 'package:krow/core/entity/event_entity.dart';
class EventsState {
final bool inLoading;
final int tabIndex;
final int subTabIndex;
final String? errorMessage;
final Map<int, Map<int, EventTabState>> tabs;
const EventsState(
{this.inLoading = false,
this.tabIndex = 0,
this.subTabIndex = 0,
this.errorMessage,
required this.tabs});
EventsState copyWith({
bool? inLoading,
int? tabIndex,
int? subTabIndex,
String? errorMessage,
Map<int, Map<int, EventTabState>>? tabs,
}) {
return EventsState(
inLoading: inLoading ?? this.inLoading,
tabIndex: tabIndex ?? this.tabIndex,
subTabIndex: subTabIndex ?? this.subTabIndex,
tabs: tabs ?? this.tabs,
errorMessage: errorMessage,
);
}
}
class EventTabState {
final List<EventEntity> items;
final bool inLoading;
final bool hasMoreItems;
final EventStatus status;
const EventTabState({
required this.items,
this.inLoading = false,
this.hasMoreItems = true,
required this.status,
});
EventTabState copyWith({
List<EventEntity>? items,
bool? inLoading,
bool? hasMoreItems,
}) {
return EventTabState(
items: items ?? this.items,
inLoading: inLoading ?? this.inLoading,
hasMoreItems: hasMoreItems ?? this.hasMoreItems,
status: status,
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:krow/core/entity/event_entity.dart';
abstract class EventsRepository {
Stream<EventStatus> get statusStream;
Future<List<EventEntity>> getEvents(
{String? lastItemId, required EventStatus statusFilter});
Future<EventEntity?> getEventById(String id);
void dispose();
Future<void> cancelClientEvent(String id, EventStatus? status);
Future<void> completeClientEvent(String id, {String? comment});
Future<void> trackClientClockin(String positionStaffId);
Future<void> trackClientClockout(String positionStaffId);
Future<void> notShowedPositionStaff(String positionStaffId);
Future<void> replacePositionStaff(String positionStaffId, String reason);
void refreshEvents(EventStatus status);
}

View File

@@ -0,0 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
@RoutePage()
class EventsFlowScreen extends StatelessWidget {
const EventsFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return const AutoRouter();
}
}

View File

@@ -0,0 +1,156 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/entity/event_entity.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_popup_button.dart';
import 'package:krow/features/events/domain/blocs/details/event_details_bloc.dart';
import 'package:krow/features/events/domain/events_repository.dart';
import 'package:krow/features/events/presentation/event_details/widgets/event_completed_by_card_widget.dart';
import 'package:krow/features/events/presentation/event_details/widgets/event_info_card_widget.dart';
import 'package:krow/features/events/presentation/event_details/widgets/shift/shift_widget.dart';
import 'package:krow/features/home/presentation/home_screen.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
import '../../domain/blocs/events_list_bloc/events_bloc.dart';
import '../../domain/blocs/events_list_bloc/events_event.dart';
@RoutePage()
class EventDetailsScreen extends StatefulWidget implements AutoRouteWrapper {
final EventEntity event;
final bool isPreview;
const EventDetailsScreen(
{super.key, required this.event, this.isPreview = false});
@override
State<EventDetailsScreen> createState() => _EventDetailsScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
key: Key(event.id),
create: (context) {
return EventDetailsBloc(event, getIt<EventsRepository>(),isPreview)
..add(EventDetailsInitialEvent());
},
child: this,
);
}
}
class _EventDetailsScreenState extends State<EventDetailsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: widget.isPreview ? 'Event Preview' : 'Event Details',
),
body: BlocConsumer<EventDetailsBloc, EventDetailsState>(
listenWhen: (previous, current) =>
previous.showErrorPopup != current.showErrorPopup ||
previous.needDeepPop != current.needDeepPop,
listener: (context, state) {
if (state.needDeepPop) {
context.router.popUntilRoot();
homeContext?.maybePop();
return;
}
if (state.showErrorPopup != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.showErrorPopup ?? ''),
));
}
},
builder: (context, state) {
return ModalProgressHUD(
inAsyncCall: state.inLoading,
child: ScrollLayoutHelper(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24),
upperWidget: Column(
children: [
EventInfoCardWidget(
item: state.event,
isPreview: widget.isPreview,
),
EventCompletedByCardWidget(
completedBy: state.event.completedBy,
completedNote: state.event.completedNode,
),
ListView.builder(
shrinkWrap: true,
primary: false,
itemCount: state.shifts.length,
itemBuilder: (context, index) {
return ShiftWidget(
index: index, shiftState: state.shifts[index]);
},
),
],
),
lowerWidget: widget.isPreview
? _buildPreviewButtons()
: const SizedBox.shrink()),
);
},
),
);
}
Widget _buildPreviewButtons() {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: KwPopUpButton(
label: widget.event.status == null ||
widget.event.status == EventStatus.draft
? widget.event.id.isEmpty
? 'Save as Draft'
: 'Update Draft'
: 'Save Event',
popUpPadding: 16,
items: [
KwPopUpButtonItem(
title: 'Publish Event',
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(DetailsPublishEvent());
},
),
if (widget.event.status == EventStatus.draft ||
widget.event.status == null)
KwPopUpButtonItem(
title: widget.event.id.isEmpty ? 'Save as Draft' : 'Update Draft',
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(CreateEventPostEvent());
},
),
KwPopUpButtonItem(
title: widget.event.status == null ||
widget.event.status == EventStatus.draft
? 'Edit Event Draft'
: 'Edit Event',
onTap: () {
context.router.maybePop();
},
color: AppColors.primaryBlue),
if (widget.event.status == EventStatus.draft ||
widget.event.status == null)
KwPopUpButtonItem(
title: 'Delete Event Draft',
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(DetailsDeleteDraftEvent());
BlocProvider.of<EventsBloc>(context)
.add(LoadTabEventEvent(tabIndex: 3, subTabIndex: 0));
},
color: AppColors.statusError),
],
),
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:auto_route/auto_route.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/entity/event_entity.dart';
import 'package:krow/core/presentation/gen/assets.gen.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/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/events/domain/blocs/details/event_details_bloc.dart';
import 'package:krow/features/events/presentation/event_details/widgets/event_qr_popup.dart';
class EventButtonGroupWidget extends StatelessWidget {
final EventEntity item;
final bool isPreview;
const EventButtonGroupWidget(
{super.key, required this.item, required this.isPreview});
@override
Widget build(BuildContext context) {
if (isPreview) return const SizedBox.shrink();
final now = DateTime.now();
final startTime =
item.startDate ?? DateTime.now(); // adjust property name if needed
final hoursUntilStart = startTime.difference(now).inHours;
final showDraftButton = hoursUntilStart >= 24;
switch (item.status) {
case EventStatus.confirmed:
return Column(children: [
_buildActiveButtonGroup(context),
Gap(8),
_buildConfirmedButtonGroup(context)
]);
case EventStatus.active:
return _buildActiveButtonGroup(context);
case EventStatus.finished:
return _buildCompletedButtonGroup(context);
case EventStatus.pending:
case EventStatus.assigned:
return Column(children: [
if (showDraftButton) _buildDraftButtonGroup(context),
if (showDraftButton) Gap(8),
_buildConfirmedButtonGroup(context)
]);
case EventStatus.draft:
return Column(children: [
_buildDraftButtonGroup(context)
]);
case EventStatus.completed:
case EventStatus.closed:
// return _buildClosedButtonGroup();
default:
return const SizedBox.shrink();
}
}
Widget _buildActiveButtonGroup(BuildContext context) {
return Column(
children: [
KwButton.primary(
label: 'QR Code',
onPressed: () {
EventQrPopup.show(context, item);
},
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'Clock Manually',
onPressed: () {
context.router.push(ClockManualRoute(
staffContacts: item.shifts
?.expand(
(s) => s.positions.expand((p) => p.staffContacts))
.toList() ??
[]));
},
),
],
);
}
Widget _buildConfirmedButtonGroup(BuildContext context) {
return KwButton.outlinedPrimary(
label: 'Cancel Event',
onPressed: () {
BlocProvider.of<EventDetailsBloc>(context).add(CancelClientEvent());
},
).copyWith(
color: AppColors.statusError,
textColors: AppColors.statusError,
borderColor: AppColors.statusError);
}
Widget _buildDraftButtonGroup(BuildContext context) {
return Column(
children: [
KwButton.accent(
label: 'Edit Event',
onPressed: () async {
BlocProvider.of<EventDetailsBloc>(context).add(
DisablePollingEvent(),
);
await context.router.push(
CreateEventFlowRoute(children: [
CreateEventRoute(
eventModel: item.dto,
),
]),
);
BlocProvider.of<EventDetailsBloc>(context).add(
RefreshEventDetailsEvent(),
);
BlocProvider.of<EventDetailsBloc>(context).add(
EnablePollingEvent(),
);
},
),
// Gap(8),
// KwButton.outlinedPrimary(
// label: 'Delete Event Draft',
// onPressed: () {
// BlocProvider.of<EventDetailsBloc>(context)
// .add(DetailsDeleteDraftEvent());
// },
// ).copyWith(
// color: AppColors.statusError,
// textColors: AppColors.statusError,
// borderColor: AppColors.statusError),
],
);
}
Widget _buildCompletedButtonGroup(context) {
return Column(
children: [
KwButton.primary(
label: 'Complete Event',
onPressed: () {
_completeEvent(context);
},
),
],
);
}
Widget _buildClosedButtonGroup() {
return Column(
children: [
KwButton.primary(
label: 'View Invoice',
onPressed: () {},
),
],
);
}
void _completeEvent(BuildContext context) async {
var controller = TextEditingController();
var result = await KwDialog.show(
context: context,
icon: Assets.images.icons.navigation.confetti,
title: 'Complete Event',
message: 'Please tell us how did the event went:',
state: KwDialogState.info,
child: KwTextInput(
controller: controller,
maxLength: 300,
showCounter: true,
minHeight: 144,
hintText: 'Enter your note here...',
title: 'Note (optional)',
),
primaryButtonLabel: 'Complete Event',
onPrimaryButtonPressed: (dialogContext) {
BlocProvider.of<EventDetailsBloc>(context)
.add(CompleteEventEvent(comment: controller.text));
Navigator.of(dialogContext).pop(true);
},
secondaryButtonLabel: 'Cancel',
);
if (result) {
await KwDialog.show(
context: context,
icon: Assets.images.icons.navigation.confetti,
title: 'Thanks!',
message:
'Thank you for using our app! We hope youve had an awesome event!',
primaryButtonLabel: 'Close',
);
}
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/business_member_model.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 EventCompletedByCardWidget extends StatelessWidget {
final BusinessMemberModel? completedBy;
final String? completedNote;
const EventCompletedByCardWidget(
{super.key, required this.completedBy, required this.completedNote});
@override
Widget build(BuildContext context) {
if (completedBy == null) return Container();
return Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Completed by',
style:
AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
),
const Gap(4),
_contact(),
const Gap(12),
Text(
'Note',
style:
AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
),
const Gap(2),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
completedNote ?? '',
style: AppTextStyles.bodyMediumMed,
),
],
)
],
),
);
}
Container _contact() {
return Container(
height: 28,
padding: const EdgeInsets.only(left: 2, right: 12, top: 2, bottom: 2),
decoration: BoxDecoration(
color: AppColors.tintBlue,
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24,
width: 24,
decoration: BoxDecoration(
color: AppColors.blackGray,
borderRadius: BorderRadius.circular(12),
)),
const Gap(8),
Text(
'${completedBy?.firstName} ${completedBy?.lastName}',
style: AppTextStyles.bodyMediumMed,
),
],
),
);
}
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/application/common/str_extensions.dart';
import 'package:krow/core/data/models/event/addon_model.dart';
import 'package:krow/core/entity/event_entity.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/icon_row_info_widget.dart';
import 'package:krow/features/events/presentation/event_details/widgets/event_button_group_widget.dart';
class EventInfoCardWidget extends StatelessWidget {
final EventEntity item;
final bool isPreview;
const EventInfoCardWidget(
{required this.item, super.key, this.isPreview = false});
Color getIconColor(EventStatus? status) {
return switch (status) {
EventStatus.active || EventStatus.finished => AppColors.statusSuccess,
EventStatus.pending ||
EventStatus.assigned ||
EventStatus.confirmed =>
AppColors.primaryBlue,
_ => AppColors.statusWarning
};
}
Color getIconBgColor(EventStatus? status) {
switch (status) {
case EventStatus.active:
case EventStatus.finished:
return AppColors.tintGreen;
case EventStatus.pending:
case EventStatus.assigned:
case EventStatus.confirmed:
return AppColors.tintBlue;
default:
return AppColors.tintOrange;
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
margin: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusRow(),
const Gap(24),
Text(item.name, style: AppTextStyles.headingH1),
const Gap(24),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'Date',
value: DateFormat('MM.dd.yyyy')
.format(item.startDate ?? DateTime.now()),
),
const Gap(12),
IconRowInfoWidget(
icon: Assets.images.icons.location.svg(),
title: 'Location',
value: item.hub?.name ?? 'Hub Name',
),
const Gap(12),
ValueListenableBuilder(
valueListenable: item.totalCost,
builder: (context, value, child) {
return IconRowInfoWidget(
icon: Assets.images.icons.dollarSquare.svg(),
title: 'Value',
value: '\$${value.toStringAsFixed(2)}',
);
}),
const Gap(24),
const Divider(
color: AppColors.grayTintStroke,
thickness: 1,
height: 0,
),
_buildAddons(item.addons),
..._buildAdditionalInfo(),
if (!isPreview) const Gap(24),
EventButtonGroupWidget(item: item, isPreview: isPreview),
],
),
);
}
List<Widget> _buildAdditionalInfo() {
return [
const Gap(12),
Text('Additional Information',
style:
AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray)),
const Gap(2),
Text(
(item.additionalInfo == null || item.additionalInfo!.trim().isEmpty)
? 'No additional information'
: item.additionalInfo!,
style: AppTextStyles.bodyMediumMed),
];
}
Widget _buildAddons(List<AddonModel>? selectedAddons) {
if (item.addons == null || item.addons!.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
...selectedAddons?.map((addon) {
var textStyle = AppTextStyles.bodyMediumMed.copyWith(height: 1);
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(addon.name ?? '', style: textStyle),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Assets.images.icons.addonInclude.svg(),
const Gap(7),
Text('Included', style: textStyle)
],
)
],
),
);
}) ??
[],
],
);
}
Row _buildStatusRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: getIconBgColor(item.status),
shape: BoxShape.circle,
),
child: Center(
child: Assets.images.icons.navigation.confetti.svg(
colorFilter:
ColorFilter.mode(getIconColor(item.status), BlendMode.srcIn),
),
),
),
if (!isPreview)
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: item.status?.color,
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Text(
item.status?.name.capitalize() ?? '',
style: AppTextStyles.bodySmallMed.copyWith(
color: item.status == EventStatus.draft
? null
: AppColors.grayWhite),
),
),
))
],
);
}
}
extension on EventStatus {
Color get color {
switch (this) {
case EventStatus.active:
case EventStatus.finished:
return AppColors.statusSuccess;
case EventStatus.pending:
return AppColors.blackGray;
case EventStatus.assigned:
return AppColors.statusWarning;
case EventStatus.confirmed:
return AppColors.primaryBlue;
case EventStatus.completed:
return AppColors.statusSuccess;
case EventStatus.closed:
return AppColors.bgColorDark;
case EventStatus.canceled:
return AppColors.statusError;
case EventStatus.draft:
return AppColors.primaryYolk;
}
}
}

View File

@@ -0,0 +1,152 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:gap/gap.dart';
import 'package:image/image.dart' as img;
import 'package:krow/core/entity/event_entity.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:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
class EventQrPopup {
static Future<void> show(BuildContext context, EventEntity item) async {
var qrKey = GlobalKey();
return showDialog<void>(
context: context,
builder: (context) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: KwBoxDecorations.white24,
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 24, left: 24, right: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Event QR code',
style: AppTextStyles.headingH3.copyWith(height: 1),
),
GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Assets.images.icons.x.svg(
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.blackCaptionText,
BlendMode.srcIn,
),
),
),
],
),
),
RepaintBoundary(
key: qrKey,
child: Padding(
padding: const EdgeInsets.only(left: 24, right: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Gap(8),
Text(
'The QR code below has been successfully generated for the ${item.name}. You can share this code with staff members to enable them to clock in.',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
const Gap(24),
QrImageView(
data: jsonEncode({
'type': 'event',
'birth': 'app',
'eventId': item.id
}),
version: QrVersions.auto,
size: MediaQuery.of(context).size.width - 56,
),
const Gap(24),
],
),
),
),
Padding(
padding: const EdgeInsets.only(
bottom: 24, left: 24, right: 24),
child: KwButton.primary(
label: 'Share QR Code',
onPressed: () async {
final params = ShareParams(
text: 'Qr code for ${item.name}',
files: [
XFile.fromData(
await removeAlphaAndReplaceTransparentWithWhite(
qrKey.currentContext!.findRenderObject()
as RenderRepaintBoundary),
name: 'event_qr.png',
mimeType: 'image/png',
)
],
);
await SharePlus.instance.share(params);
},
),
),
],
),
),
),
);
});
}
static Future<Uint8List> removeAlphaAndReplaceTransparentWithWhite(
RenderRepaintBoundary boundary) async {
final image = await boundary.toImage(pixelRatio: 5.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
final Uint8List rgba = byteData!.buffer.asUint8List();
final int length = rgba.lengthInBytes;
final Uint8List rgb = Uint8List(length ~/ 4 * 3);
for (int i = 0, j = 0; i < length; i += 4, j += 3) {
int r = rgba[i];
int g = rgba[i + 1];
int b = rgba[i + 2];
int a = rgba[i + 3];
if (a < 255) {
// Replace transparent pixel with white
r = 255;
g = 255;
b = 255;
}
rgb[j] = r;
rgb[j + 1] = g;
rgb[j + 2] = b;
}
final width = image.width;
final height = image.height;
final img.Image rgbImage = img.Image.fromBytes(
width: width,
height: height,
bytes: rgb.buffer,
numChannels: 3,
format: img.Format.uint8,
);
return Uint8List.fromList(img.encodePng(rgbImage));
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:math';
import 'package:auto_route/auto_route.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/assigned_staff_item_widget.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/events/domain/blocs/details/event_details_bloc.dart';
class AssignedStaff extends StatelessWidget {
final PositionState roleState;
const AssignedStaff({super.key, required this.roleState});
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildRoleHeader(context),
if (roleState.isStaffExpanded)
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: roleState.isStaffExpanded ? null : 0,
child: Column(children: [
ListView.builder(
shrinkWrap: true,
primary: false,
itemCount: min(3, roleState.position.staffContacts.length),
itemBuilder: (context, index) {
return AssignedStaffItemWidget(
staffContact: roleState.position.staffContacts[index],
department: roleState.position.department?.name ?? '',
);
}),
KwButton.outlinedPrimary(
label: 'View All',
onPressed: () {
context.pushRoute(AssignedStaffRoute(
staffContacts: roleState.position.staffContacts,
department: roleState.position.department?.name ?? '',
));
}),
const Gap(8),
])),
],
);
}
Widget _buildRoleHeader(context) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: GestureDetector(
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(OnAssignedStaffHeaderTapEvent(roleState));
},
child: Container(
color: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Assigned Staff', style: AppTextStyles.bodyLargeMed),
Container(
height: 16,
width: 16,
color: Colors.transparent,
child: AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: roleState.isStaffExpanded ? 0.5 : 0,
child: Center(
child: Assets.images.icons.chevronDown.svg(
colorFilter: ColorFilter.mode(
roleState.isStaffExpanded
? AppColors.blackBlack
: AppColors.blackCaptionText,
BlendMode.srcIn,
),
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,139 @@
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/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/icon_row_info_widget.dart';
import 'package:krow/features/events/domain/blocs/details/event_details_bloc.dart';
import 'package:krow/features/events/presentation/event_details/widgets/role/asigned_staff.dart';
class RoleWidget extends StatefulWidget {
final PositionState roleState;
const RoleWidget({super.key, required this.roleState});
@override
State<RoleWidget> createState() => _RoleWidgetState();
}
class _RoleWidgetState extends State<RoleWidget> {
@override
Widget build(BuildContext context) {
var eventDate =
widget.roleState.position.parentShift?.parentEvent?.startDate ??
DateTime.now();
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.only(left: 12, right: 12),
margin: const EdgeInsets.only(top: 8),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRoleHeader(context),
AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.topCenter,
child: Container(
height: widget.roleState.isExpanded ? null : 0,
decoration: const BoxDecoration(),
child: Column(
children: [
IconRowInfoWidget(
icon: Assets.images.icons.data.svg(),
title: 'Department',
value: widget.roleState.position.department?.name ?? ''),
const Gap(16),
IconRowInfoWidget(
icon: Assets.images.icons.profile2user.svg(),
title: 'Number of Employee for one Role',
value: '${widget.roleState.position.count} persons'),
const Gap(16),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'Start Date & Time',
value: DateFormat('MM.dd.yyyy, hh:mm a').format(
eventDate.copyWith(
hour: widget.roleState.position.startTime.hour,
minute:
widget.roleState.position.startTime.minute))),
const Gap(16),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'End Date & Time',
value: DateFormat('MM.dd.yyyy, hh:mm a').format(
eventDate.copyWith(
hour: widget.roleState.position.endTime.hour,
minute:
widget.roleState.position.endTime.minute))),
const Gap(16),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: IconRowInfoWidget(
icon: Assets.images.icons.dollarSquare.svg(),
title: 'Value',
value: '\$${widget.roleState.position.price.toStringAsFixed(2)}'),
),
if (widget.roleState.position.staffContacts.isNotEmpty) ...[
const Gap(16),
const Divider(
color: AppColors.grayTintStroke,
thickness: 1,
height: 0),
AssignedStaff(
roleState: widget.roleState,
),
]
],
),
),
),
],
),
);
}
Widget _buildRoleHeader(context) {
return GestureDetector(
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(OnRoleHeaderTapEvent(widget.roleState));
},
child: Container(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.roleState.position.businessSkill?.skill?.name ?? '',
style: AppTextStyles.headingH3),
Container(
height: 16,
width: 16,
color: Colors.transparent,
child: AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: widget.roleState.isExpanded ? 0.5 : 0,
child: Center(
child: Assets.images.icons.chevronDown.svg(
colorFilter: ColorFilter.mode(
widget.roleState.isExpanded
? AppColors.blackBlack
: AppColors.blackCaptionText,
BlendMode.srcIn,
),
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/event/business_member_model.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/icon_row_info_widget.dart';
import 'package:krow/features/events/domain/blocs/details/event_details_bloc.dart';
import 'package:krow/features/events/presentation/event_details/widgets/role/role_widget.dart';
class ShiftWidget extends StatefulWidget {
final int index;
final ShiftState shiftState;
const ShiftWidget({super.key, required this.index, required this.shiftState});
@override
State<ShiftWidget> createState() => _ShiftWidgetState();
}
class _ShiftWidgetState extends State<ShiftWidget> {
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.only(left: 12, right: 12),
margin: const EdgeInsets.only(top: 12),
decoration: KwBoxDecorations.white12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildShiftHeader(context),
AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.topCenter,
child: Container(
height: widget.shiftState.isExpanded ? null : 0,
decoration: const BoxDecoration(),
child: Column(
children: [
IconRowInfoWidget(
icon: Assets.images.icons.location.svg(),
title: 'Address',
value: widget
.shiftState.shift.fullAddress?.formattedAddress ??
''),
const Gap(16),
const Divider(
color: AppColors.grayTintStroke, thickness: 1, height: 0),
const Gap(16),
Stack(
children: [
IconRowInfoWidget(
icon: Assets.images.icons.userTag.svg(),
title: 'Shift Contact',
value: 'Manager'),
if (widget.shiftState.shift.managers.isNotEmpty)
Positioned(
bottom: 4,
top: 4,
right: 0,
child: _contact(
widget.shiftState.shift.managers.first))
],
),
const Gap(16),
ListView.builder(
itemCount: widget.shiftState.positions.length,
primary: false,
shrinkWrap: true,
itemBuilder: (_, index) {
return RoleWidget(
roleState: widget.shiftState.positions[index],
);
},
),
const Gap(12),
],
),
),
),
],
),
);
}
Widget _buildShiftHeader(context) {
return GestureDetector(
onTap: () {
BlocProvider.of<EventDetailsBloc>(context)
.add(OnShiftHeaderTapEvent(widget.shiftState));
},
child: Container(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 24, bottom: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Shift Details #${widget.index + 1}',
style: AppTextStyles.headingH1),
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: AppColors.grayTintStroke,
),
),
child: AnimatedRotation(
duration: const Duration(milliseconds: 200),
turns: widget.shiftState.isExpanded ? 0.5 : 0,
child: Center(
child: Assets.images.icons.chevronDown.svg(
colorFilter: ColorFilter.mode(
widget.shiftState.isExpanded
? AppColors.blackBlack
: AppColors.blackCaptionText,
BlendMode.srcIn,
),
),
),
),
),
],
),
),
),
);
}
Container _contact(BusinessMemberModel contact) {
return Container(
height: 28,
padding: const EdgeInsets.only(left: 2, right: 12, top: 2, bottom: 2),
decoration: BoxDecoration(
color: AppColors.tintBlue,
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24,
width: 24,
decoration: BoxDecoration(
color: AppColors.blackGray,
borderRadius: BorderRadius.circular(12),
)),
const Gap(8),
Text(
'${contact.firstName} ${contact.lastName}',
style: AppTextStyles.bodyMediumMed,
),
],
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/application/common/str_extensions.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/entity/event_entity.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/icon_row_info_widget.dart';
class EventListItemWidget extends StatelessWidget {
final EventEntity item;
const EventListItemWidget({required this.item, super.key});
Color getIconColor(EventStatus? status) {
switch (status) {
case EventStatus.active:
case EventStatus.finished:
return AppColors.statusSuccess;
case EventStatus.pending:
case EventStatus.assigned:
case EventStatus.confirmed:
return AppColors.primaryBlue;
default:
return AppColors.statusWarning;
}
}
Color getIconBgColor(EventStatus? status) {
switch (status) {
case EventStatus.active:
case EventStatus.finished:
return AppColors.tintGreen;
case EventStatus.pending:
case EventStatus.assigned:
case EventStatus.confirmed:
return AppColors.tintBlue;
default:
return AppColors.tintOrange;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
context.router.push(EventDetailsRoute(event: item));
},
child: Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusRow(),
const Gap(12),
Text(item.name, style: AppTextStyles.headingH1),
const Gap(4),
Text('BEO-${item.id}',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'Date',
value: DateFormat('MM.dd.yyyy')
.format(item.startDate ?? DateTime.now()),
),
const Gap(12),
IconRowInfoWidget(
icon: Assets.images.icons.location.svg(),
title: 'Location',
value: item.hub?.name ?? 'Hub Name',
),
const Gap(24),
Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: KwBoxDecorations.primaryLight8.copyWith(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Approximate Total Costs',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
ValueListenableBuilder(
valueListenable: item.totalCost,
builder: (context, value, child) {
return Text(
'\$${value.toStringAsFixed(2)}',
style: AppTextStyles.bodyMediumMed,
);
})
],
),
)
],
),
),
);
}
Row _buildStatusRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: getIconBgColor(item?.status),
shape: BoxShape.circle,
),
child: Center(
child: Assets.images.icons.navigation.confetti.svg(
colorFilter:
ColorFilter.mode(getIconColor(item.status), BlendMode.srcIn),
),
),
),
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: item.status?.color,
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Text(
item.status?.name.capitalize() ?? '',
style: AppTextStyles.bodySmallMed.copyWith(
color: item.status == EventStatus.draft
? null
: AppColors.grayWhite),
),
),
))
],
);
}
}
extension on EventStatus {
Color get color {
switch (this) {
case EventStatus.active:
case EventStatus.finished:
return AppColors.statusSuccess;
case EventStatus.pending:
return AppColors.blackGray;
case EventStatus.assigned:
return AppColors.statusWarning;
case EventStatus.confirmed:
return AppColors.primaryBlue;
case EventStatus.completed:
return AppColors.statusSuccess;
case EventStatus.closed:
return AppColors.bgColorDark;
case EventStatus.canceled:
return AppColors.statusError;
case EventStatus.draft:
return AppColors.primaryYolk;
}
}
}

View File

@@ -0,0 +1,207 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/entity/event_entity.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_option_selector.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_tabs.dart';
import 'package:krow/features/events/domain/blocs/events_list_bloc/events_bloc.dart';
import 'package:krow/features/events/domain/blocs/events_list_bloc/events_event.dart';
import 'package:krow/features/events/domain/blocs/events_list_bloc/events_state.dart';
import 'package:krow/features/events/presentation/lists/event_list_item_widget.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
@RoutePage()
class EventsListMainScreen extends StatefulWidget implements AutoRouteWrapper {
const EventsListMainScreen({super.key});
@override
State<EventsListMainScreen> createState() => _EventsListMainScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider<EventsBloc>(
create: (context) => EventsBloc()..add(const EventsInitialEvent()),
child: this,
);
}
}
class _EventsListMainScreenState extends State<EventsListMainScreen> {
final tabs = <String, List<String>>{
'Upcoming': ['Pending', 'Assigned', 'Confirmed'],
'Active': ['Ongoing', 'Finished'],
'Past': ['Completed', 'Closed', 'Canceled'],
'Drafts': [],
};
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels != 0) {
BlocProvider.of<EventsBloc>(context).add(
LoadMoreEventEvent(
tabIndex: BlocProvider.of<EventsBloc>(context).state.tabIndex,
subTabIndex:
BlocProvider.of<EventsBloc>(context).state.subTabIndex),
);
}
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<EventsBloc, EventsState>(
listenWhen: (previous, current) =>
previous.errorMessage != current.errorMessage,
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.errorMessage!),
));
}
},
builder: (context, state) {
var tabState = state.tabs[state.tabIndex]![state.subTabIndex]!;
List<EventEntity> items = tabState.items;
return Scaffold(
appBar: KwAppBar(
titleText: 'Events',
centerTitle: false,
),
body: ModalProgressHUD(
inAsyncCall: tabState.inLoading && tabState.items.isNotEmpty,
child: ScrollLayoutHelper(
padding: const EdgeInsets.symmetric(vertical: 16),
onRefresh: () async {
BlocProvider.of<EventsBloc>(context).add(LoadTabEventEvent(
tabIndex: state.tabIndex, subTabIndex: state.subTabIndex));
},
controller: _scrollController,
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
KwTabBar(
tabs: tabs.keys.toList(),
flexes: const [7, 5, 5, 5],
onTap: (index) {
BlocProvider.of<EventsBloc>(context)
.add(EventsTabChangedEvent(tabIndex: index));
}),
const Gap(24),
_buildSubTab(state, context),
if (tabState.inLoading && tabState.items.isEmpty)
..._buildListLoading(),
if (!tabState.inLoading && items.isEmpty)
..._emptyListWidget(),
RefreshIndicator(
onRefresh: () async {
BlocProvider.of<EventsBloc>(context).add(
LoadTabEventEvent(
tabIndex: state.tabIndex,
subTabIndex: state.subTabIndex));
},
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) {
return EventListItemWidget(
item: items[index],
);
}),
),
],
),
lowerWidget: const SizedBox.shrink(),
),
),
);
},
);
}
Widget _buildSubTab(EventsState state, BuildContext context) {
if (state.tabs[state.tabIndex]!.length > 1) {
return Stack(
children: [
Positioned(
left: 16,
right: 16,
bottom: 25.5,
child: Container(
height: 1,
color: AppColors.blackGray,
)),
Padding(
padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
child: KwOptionSelector(
selectedIndex: state.subTabIndex,
onChanged: (index) {
BlocProvider.of<EventsBloc>(context)
.add(EventsSubTabChangedEvent(subTabIndex: index));
},
height: 26,
selectorHeight: 4,
textStyle: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
selectedTextStyle: AppTextStyles.bodyMediumMed,
itemAlign: Alignment.topCenter,
items: tabs[tabs.keys.toList()[state.tabIndex]]!),
),
],
);
} else {
return 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),
const Text(
'You currently have no event',
textAlign: TextAlign.center,
style: AppTextStyles.headingH2,
),
];
}
}

View File

@@ -0,0 +1,191 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/data/models/event/event_model.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';
class CreateEventPopup extends StatefulWidget {
const CreateEventPopup({
super.key,
required this.child,
required this.controller,
required this.opacity,
});
final Widget child;
final ListenableOverlayPortalController controller;
final double opacity;
@override
State<CreateEventPopup> createState() => _CreateEventPopupState();
}
class _CreateEventPopupState extends State<CreateEventPopup> {
late final ListenableOverlayPortalController _controller;
@override
void initState() {
_controller = widget.controller;
_controller.addListener(() {
try {
if (context.mounted) {
setState(() {});
}
} catch (e) {
print(e);
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return OverlayPortal(
controller: _controller,
overlayChildBuilder: (context) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: widget.opacity,
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: 0.1 + 0.9 * widget.opacity,
alignment: const Alignment(0, 0.8),
child: GestureDetector(
onTap: () {
_controller.hide();
},
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 125),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// _buildItem('Create Recurrent Event', () {
// context.router.push(CreateEventFlowRoute(
// children: [
// CreateEventRoute(
// eventType: EventScheduleType.recurring)
// ]));
// }),
// const Gap(8),
_buildItem('Create Event', () {
context.router.push(CreateEventFlowRoute(
children: [
CreateEventRoute(
eventType: EventScheduleType.oneTime)
]));
}),
const Gap(6),
Assets.images.icons.navigation.menuArrow.svg(),
],
),
],
),
),
)),
),
);
},
child: widget.child,
);
}
Widget _buildItem(String title, VoidCallback onTap) {
return GestureDetector(
onTap: () {
onTap();
_controller.hide();
},
child: Container(
width: 215,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.buttonPrimaryYellowDrop,
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
title,
style: AppTextStyles.bodyMediumMed,
),
),
);
}
@override
void dispose() {
if (context.mounted) _controller.hide();
super.dispose();
}
}
class ListenableOverlayPortalController extends OverlayPortalController {
List<VoidCallback> listeners = [];
Future<void> Function()? onShow;
Future<void> Function()? onHide;
ListenableOverlayPortalController();
addOnShowListener(Future<void> Function() listener) {
onShow = listener;
}
addOnHideListener(Future<void> Function() listener) {
onHide = listener;
}
@override
void show() async {
super.show();
try {
for (var element in listeners) {
element();
}
} catch (e) {
if (kDebugMode) {
print(e);
}
}
if (onShow != null) {
await onShow!();
}
}
@override
void hide() async {
if (onHide != null) {
await onHide!();
}
try {
super.hide();
} catch (e) {
if (kDebugMode) {
print(e);
}
}
for (var element in listeners) {
try {
element();
} catch (e) {
if (kDebugMode) {
print(e);
}
}
}
}
void addListener(VoidCallback listener) {
listeners.add(listener);
}
}

View File

@@ -0,0 +1,246 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/di/injectable.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/ui_kit/kw_button.dart';
import 'package:krow/core/sevices/app_update_service.dart';
import 'package:krow/features/home/presentation/create_event_widget.dart';
import '../../notificatins/domain/bloc/notifications_bloc.dart';
BuildContext? _nestedContext;
BuildContext? homeContext;
@RoutePage()
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ListenableOverlayPortalController _controller =
ListenableOverlayPortalController();
final List<PageRouteInfo> _routes = const [
EventsFlowRoute(),
InvoiceFlowRoute(),
NotificationFlowRoute(),
ProfileFlowRoute(),
];
bool _shouldHideBottomNavBar(BuildContext nestedContext) {
final currentPath = context.router.currentPath;
return _allowBottomNavBarRoutes
.any((route) => currentPath == route.toString());
}
final List<String> _allowBottomNavBarRoutes = [
'/home/events/list',
'/home/invoice/list',
'/home/notifications/list',
'/home/profile/preview',
];
double opacity = 0.0;
getMediaQueryData(BuildContext context) {
if (!_shouldHideBottomNavBar(context)) {
return MediaQuery.of(context);
} else {
//76 - navigation height, 22 - bottom padding
var newBottomPadding = MediaQuery.of(context).padding.bottom + 76 + 22;
var newPadding =
MediaQuery.of(context).padding.copyWith(bottom: newBottomPadding);
return MediaQuery.of(context).copyWith(padding: newPadding);
}
}
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() {});
});
_controller.addOnHideListener(_hide);
_controller.addOnShowListener(_show);
WidgetsBinding.instance.addPostFrameCallback((call) {
getIt<AppUpdateService>().checkForUpdate(context);
});
}
@override
Widget build(BuildContext context) {
homeContext = context;
return BlocProvider(
create: (context) => NotificationsBloc()..add(NotificationsInitEvent()),
child: Scaffold(
body: AutoTabsRouter(
duration: const Duration(milliseconds: 250),
routes: _routes,
builder: (context, child) {
_nestedContext = context;
return Stack(
children: [
MediaQuery(
data: getMediaQueryData(context),
child: child,
),
_buildBarrier(),
if (_shouldHideBottomNavBar(context)) _bottomNavBar(context),
],
);
},
),
),
);
}
Widget _buildBarrier() {
return IgnorePointer(
ignoring: opacity == 0.0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: opacity,
child: GestureDetector(
onTap: () {
_controller.hide();
},
child: const SizedBox(
height: double.maxFinite,
width: double.maxFinite,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black38,
),
),
),
),
),
);
}
Widget _bottomNavBar(context) {
final tabsRouter = AutoTabsRouter.of(context);
return Positioned(
bottom: 22,
left: 16,
right: 16,
child: SafeArea(
top: false,
child: Container(
height: 76,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(38),
color: AppColors.bgColorDark,
),
child: Row(
children: [
_navBarItem('Events', Assets.images.icons.navigation.confetti, 0,
tabsRouter),
_navBarItem('Invoice', Assets.images.icons.navigation.emptyWallet,
1, tabsRouter),
Gap(8),
_createEventButton(),
Gap(8),
_navBarItem('Alerts', Assets.images.icons.appBar.notification, 2,
tabsRouter),
_navBarItem('Profile', Assets.images.icons.navigation.profile, 3,
tabsRouter),
],
),
),
),
);
}
Widget _navBarItem(
String text,
SvgGenImage icon,
int index,
tabsRouter,
) {
var color = tabsRouter.activeIndex == index
? AppColors.grayWhite
: AppColors.tintDarkBlue;
return Expanded(
child: GestureDetector(
onTap: () {
if(index<0) return;
if (_controller.isShowing) {
_controller.hide();
}
tabsRouter.setActiveIndex(index);
},
child: Container(
color: Colors.transparent,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon.svg(
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
),
),
const Gap(8),
Text(
text,
style: (tabsRouter.activeIndex == index
? AppTextStyles.bodyTinyMed
: AppTextStyles.bodyTinyReg)
.copyWith(color: color),
),
],
),
),
),
);
}
_createEventButton() {
return CreateEventPopup(
opacity: opacity,
controller: _controller,
child: KwButton.accent(
onPressed: () {
if (_controller.isShowing) {
_controller.hide();
} else {
_controller.show();
}
},
fit: KwButtonFit.circular,
iconSize: 24,
leftIcon: Assets.images.icons.navigation.noteFavorite,
).copyWith(
originalIconsColors: true,
color: _controller.isShowing
? AppColors.buttonPrimaryYellowDrop
: null));
}
Future<void> _hide() async {
setState(() {
opacity = 0.0;
});
await Future.delayed(const Duration(milliseconds: 200), () {});
}
Future<void> _show() async {
WidgetsBinding.instance.addPostFrameCallback((call) {
setState(() {
opacity = 1.0;
});
});
}
}

View File

@@ -0,0 +1,61 @@
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/data/models/pagination_wrapper/pagination_wrapper.dart';
import 'invoices_gql.dart';
import 'models/invoice_model.dart';
@Injectable()
class InvoiceApiProvider {
final ApiClient _client;
InvoiceApiProvider({required ApiClient client}) : _client = client;
Future<PaginationWrapper> fetchInvoices(String? status,
{String? after}) async {
final QueryResult result = await _client.query(
schema: getInvoicesQuery,
body: {
if (status != null) 'status': status,
'first': 20,
'after': after
});
if (result.hasException) {
throw Exception(result.exception.toString());
}
return PaginationWrapper.fromJson(
result.data!['client_invoices'], (json) => InvoiceModel.fromJson(json));
}
Future<void>approveInvoice({required String invoiceId}) async{
final QueryResult result = await _client.mutate(
schema: approveInvoiceMutation,
body: {
'id': invoiceId,
});
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
Future<void> disputeInvoice({
required String invoiceId,
required String reason,
required String comment,
}) async {
final QueryResult result = await _client.mutate(
schema: disputeInvoiceMutation,
body: {
'id': invoiceId,
'reason': reason,
'comment': comment,
});
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
}

View File

@@ -0,0 +1,145 @@
const String getInvoicesQuery = '''
query GetInvoices (\$status: InvoiceStatus, \$first: Int!, \$after: String) {
client_invoices( status: \$status, first: \$first, after: \$after) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
total
count
currentPage
lastPage
}
edges {
...invoice
cursor
}
}
}
fragment invoice on InvoiceEdge {
node {
id
business {
id
name
avatar
registration
contact{
id
first_name
last_name
email
phone
}
}
event {
id
status
start_time
end_time
contract_type
...hub
name
date
purchase_order
}
status
contract_type
contract_value
total
addons_amount
work_amount
issued_at
due_at
...items
dispute {
id
reason
details
support_note
}
}
}
fragment items on Invoice {
items {
id
staff {
id
first_name
last_name
email
phone
address
}
position {
id
start_time
end_time
break
count
rate
...business_skill
staff{
id
pivot {
id
status
start_at
end_at
clock_in
clock_out
}
}
}
work_hours
rate
addons_amount
work_amount
total_amount
}
}
fragment business_skill on EventShiftPosition {
business_skill {
id
skill {
id
name
slug
}
price
is_active
}
}
fragment hub on Event {
hub {
id
name
full_address {
formatted_address
}
}
}
''';
const String approveInvoiceMutation = '''
mutation ApproveInvoice(\$id: ID!) {
confirm_client_invoice(id: \$id) {
id
}
}
''';
const String disputeInvoiceMutation = '''
mutation DisputeInvoice(\$id: ID!, \$reason: String!, \$comment: String!) {
dispute_client_invoice(id: \$id, reason: \$reason, details: \$comment) {
id
}
}
''';

View File

@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:krow/features/invoice/data/invoices_api_provider.dart';
import 'package:krow/features/invoice/domain/invoice_entity.dart';
import 'package:krow/features/invoice/domain/invoices_repository.dart';
@Singleton(as: InvoicesRepository)
class InvoicesRepositoryImpl extends InvoicesRepository {
final InvoiceApiProvider _apiProvider;
StreamController<InvoiceStatusFilterType>? _statusController;
InvoicesRepositoryImpl({required InvoiceApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Stream<InvoiceStatusFilterType> get statusStream {
_statusController ??= StreamController<InvoiceStatusFilterType>.broadcast();
return _statusController!.stream;
}
@override
void dispose() {
_statusController?.close();
}
@override
Future<List<InvoiceListEntity>> getInvoices(
{String? lastItemId,
required InvoiceStatusFilterType statusFilter}) async {
var paginationWrapper = await _apiProvider.fetchInvoices(
statusFilter == InvoiceStatusFilterType.all ? null : statusFilter.name,
after: lastItemId);
return paginationWrapper.edges.map((e) {
return InvoiceListEntity.fromModel(
e.node,
cursor: (paginationWrapper.pageInfo.hasNextPage) ? e.cursor : null,
);
}).toList();
}
@override
Future<void> approveInvoice({required String invoiceId}) async{
await _apiProvider.approveInvoice(invoiceId: invoiceId).then((value) {
_statusController?.add(InvoiceStatusFilterType.verified);
});
_statusController?.add(InvoiceStatusFilterType.all);
_statusController?.add(InvoiceStatusFilterType.open);
}
@override
Future<void> disputeInvoice({
required String invoiceId,
required String reason,
required String comment,
}) async {
await _apiProvider.disputeInvoice(
invoiceId: invoiceId,
reason: reason,
comment: comment,
);
_statusController?.add(InvoiceStatusFilterType.disputed);
_statusController?.add(InvoiceStatusFilterType.all);
_statusController?.add(InvoiceStatusFilterType.open);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
part 'invoice_decline_model.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class InvoiceDeclineModel{
String id;
String? reason;
String? details;
String? supportNote;
InvoiceDeclineModel({
required this.id,
required this.reason,
this.details,
this.supportNote,
});
factory InvoiceDeclineModel.fromJson(Map<String, dynamic> json) {
return _$InvoiceDeclineModelFromJson(json);
}
Map<String, dynamic> toJson() => _$InvoiceDeclineModelToJson(this);
}

View File

@@ -0,0 +1,35 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/core/data/models/shift/event_shift_position_model.dart';
import 'package:krow/core/data/models/staff/staff_model.dart';
import 'package:krow/core/entity/staff_contact_entity.dart';
import 'package:krow/features/events/presentation/event_details/widgets/role/asigned_staff.dart';
part 'invoice_item_model.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class InvoiceItemModel {
final String id;
final StaffModel? staff;
final EventShiftPositionModel? position;
final double? workHours;
final double? rate;
final double? addonsAmount;
final double? workAmount;
final double? totalAmount;
InvoiceItemModel(
{required this.id,
required this.staff,
required this.position,
required this.workHours,
required this.rate,
required this.addonsAmount,
required this.workAmount,
required this.totalAmount});
factory InvoiceItemModel.fromJson(Map<String, dynamic> json) {
return _$InvoiceItemModelFromJson(json);
}
Map<String, dynamic> toJson() => _$InvoiceItemModelToJson(this);
}

View File

@@ -0,0 +1,75 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/core/data/models/event/business_model.dart';
import 'package:krow/core/data/models/event/event_model.dart';
import 'package:krow/features/invoice/data/models/invoice_decline_model.dart';
import 'package:krow/features/invoice/data/models/invoice_item_model.dart';
part 'invoice_model.g.dart';
enum InvoiceStatus { open, disputed, resolved, paid, overdue, verified }
@JsonSerializable(fieldRename: FieldRename.snake)
class InvoiceModel {
final String id;
final InvoiceStatus status;
final BusinessModel? business;
final EventModel? event;
final String? contractType;
final String? contractValue;
final String? dueAt;
final double? total;
final double? addonsAmount;
final double? workAmount;
final List<InvoiceItemModel>? items;
final InvoiceDeclineModel? dispute;
factory InvoiceModel.fromJson(Map<String, dynamic> json) {
return _$InvoiceModelFromJson(json);
}
InvoiceModel(
{required this.id,
required this.status,
required this.business,
required this.event,
required this.contractType,
required this.contractValue,
required this.total,
required this.addonsAmount,
required this.dueAt,
required this.workAmount,
required this.items,
required this.dispute});
Map<String, dynamic> toJson() => _$InvoiceModelToJson(this);
copyWith({
String? id,
InvoiceStatus? status,
BusinessModel? business,
EventModel? event,
String? contractType,
String? contractValue,
String? dueAt,
double? total,
double? addonsAmount,
double? workAmount,
List<InvoiceItemModel>? items,
InvoiceDeclineModel? dispute,
}) {
return InvoiceModel(
id: id ?? this.id,
status: status ?? this.status,
business: business ?? this.business,
event: event ?? this.event,
contractType: contractType ?? this.contractType,
contractValue: contractValue ?? this.contractValue,
total: total ?? this.total,
addonsAmount: addonsAmount ?? this.addonsAmount,
workAmount: workAmount ?? this.workAmount,
items: items ?? this.items,
dueAt: dueAt ?? this.dueAt,
dispute: dispute ?? this.dispute,
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:bloc/bloc.dart';
import 'package:krow/core/application/clients/api/api_exception.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/features/invoice/data/models/invoice_decline_model.dart';
import 'package:krow/features/invoice/data/models/invoice_model.dart';
import 'package:krow/features/invoice/domain/invoices_repository.dart';
import 'package:meta/meta.dart';
part 'invoice_details_event.dart';
part 'invoice_details_state.dart';
class InvoiceDetailsBloc
extends Bloc<InvoiceDetailsEvent, InvoiceDetailsState> {
InvoiceDetailsBloc(InvoiceModel invoice)
: super(InvoiceDetailsState(invoiceModel: invoice)) {
on<InvoiceApproveEvent>(_onInvoiceApproveEvent);
on<InvoiceDisputeEvent>(_onInvoiceDisputeEvent);
}
void _onInvoiceApproveEvent(
InvoiceApproveEvent event, Emitter<InvoiceDetailsState> emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<InvoicesRepository>().approveInvoice(
invoiceId: state.invoiceModel?.id ?? '',
);
emit(state.copyWith(inLoading: false, success: true));
return;
} catch (e) {
if (e is DisplayableException) {
emit(state.copyWith(inLoading: false, showErrorPopup: e.message));
return;
}
}
emit(state.copyWith(inLoading: false));
}
void _onInvoiceDisputeEvent(
InvoiceDisputeEvent event, Emitter<InvoiceDetailsState> emit) async {
emit(state.copyWith(inLoading: true));
try {
await getIt<InvoicesRepository>().disputeInvoice(
invoiceId: state.invoiceModel?.id ?? '',
reason: event.reason,
comment: event.comment,
);
emit(
state.copyWith(
inLoading: false,
invoiceModel: state.invoiceModel?.copyWith(
status: InvoiceStatus.disputed,
dispute: InvoiceDeclineModel(
id: '1',
reason: event.reason,
details: event.comment,
),
),
),
);
return;
} catch (e) {
print(e);
if (e is DisplayableException) {
emit(state.copyWith(inLoading: false, showErrorPopup: e.message));
return;
}
}
emit(state.copyWith(inLoading: false));
}
}

View File

@@ -0,0 +1,18 @@
part of 'invoice_details_bloc.dart';
@immutable
sealed class InvoiceDetailsEvent {}
class InvoiceApproveEvent extends InvoiceDetailsEvent {
InvoiceApproveEvent();
}
class InvoiceDisputeEvent extends InvoiceDetailsEvent {
final String reason;
final String comment;
InvoiceDisputeEvent({
required this.reason,
required this.comment,
});
}

View File

@@ -0,0 +1,20 @@
part of 'invoice_details_bloc.dart';
@immutable
class InvoiceDetailsState {
final String? showErrorPopup;
final bool inLoading;
final bool success;
final InvoiceModel? invoiceModel;
const InvoiceDetailsState( {this.showErrorPopup, this.inLoading = false,this.invoiceModel, this.success = false});
InvoiceDetailsState copyWith({String? showErrorPopup, bool? inLoading, InvoiceModel? invoiceModel, bool? success}) {
return InvoiceDetailsState(
showErrorPopup: showErrorPopup ?? this.showErrorPopup,
inLoading: inLoading ?? false,
success: success ?? this.success,
invoiceModel: invoiceModel ?? this.invoiceModel,
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import '../../invoice_entity.dart';
import '../../invoices_repository.dart';
import 'invoice_event.dart';
import 'invoice_state.dart';
class InvoiceBloc extends Bloc<InvoiceEvent, InvoicesState> {
var indexToStatus = <int, InvoiceStatusFilterType>{
0: InvoiceStatusFilterType.all,
1: InvoiceStatusFilterType.open,
2: InvoiceStatusFilterType.disputed,
3: InvoiceStatusFilterType.resolved,
4: InvoiceStatusFilterType.paid,
5: InvoiceStatusFilterType.overdue,
6: InvoiceStatusFilterType.verified,
};
InvoiceBloc()
: super(const InvoicesState(tabs: {
0: InvoiceTabState(items: [], inLoading: true),
1: InvoiceTabState(items: []),
2: InvoiceTabState(items: []),
3: InvoiceTabState(items: []),
4: InvoiceTabState(items: []),
5: InvoiceTabState(items: []),
6: InvoiceTabState(items: []),
})) {
on<InvoiceInitialEvent>(_onInitial);
on<InvoiceTabChangedEvent>(_onTabChanged);
on<LoadTabInvoiceEvent>(_onLoadTabItems);
on<LoadMoreInvoiceEvent>(_onLoadMoreTabItems);
getIt<InvoicesRepository>().statusStream.listen((event) {
add(LoadTabInvoiceEvent(status: event.index));
});
}
Future<void> _onInitial(InvoiceInitialEvent event, emit) async {
add(const LoadTabInvoiceEvent(status: 0));
}
Future<void> _onTabChanged(InvoiceTabChangedEvent event, emit) async {
emit(state.copyWith(tabIndex: event.tabIndex));
final currentTabState = state.tabs[event.tabIndex]!;
if (currentTabState.items.isEmpty && !currentTabState.inLoading) {
add(LoadTabInvoiceEvent(status: event.tabIndex));
}
}
Future<void> _onLoadTabItems(LoadTabInvoiceEvent event, emit) async {
await _fetchInvoices(event.status, null, emit);
}
Future<void> _onLoadMoreTabItems(LoadMoreInvoiceEvent event, emit) async {
final currentTabState = state.tabs[event.status]!;
if (!currentTabState.hasMoreItems || currentTabState.inLoading) return;
await _fetchInvoices(event.status, currentTabState.items, emit);
}
_fetchInvoices(
int tabIndex, List<InvoiceListEntity>? 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(inLoading: true),
},
));
try {
var items = await getIt<InvoicesRepository>().getInvoices(
statusFilter: indexToStatus[tabIndex]!,
lastItemId: previousItems?.lastOrNull?.cursor,
);
// if(items.isNotEmpty){
// items = List.generate(20, (i)=>items[0]);
// }
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: currentTabState.copyWith(
items: (previousItems ?? [])..addAll(items),
hasMoreItems: items.isNotEmpty,
inLoading: false,
),
},
));
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
emit(state.copyWith(
tabs: {
...state.tabs,
tabIndex: currentTabState.copyWith(inLoading: false),
},
));
}
}
@override
Future<void> close() {
getIt<InvoicesRepository>().dispose();
return super.close();
}
}

View File

@@ -0,0 +1,25 @@
sealed class InvoiceEvent {
const InvoiceEvent();
}
class InvoiceInitialEvent extends InvoiceEvent {
const InvoiceInitialEvent();
}
class InvoiceTabChangedEvent extends InvoiceEvent {
final int tabIndex;
const InvoiceTabChangedEvent({required this.tabIndex});
}
class LoadTabInvoiceEvent extends InvoiceEvent {
final int status;
const LoadTabInvoiceEvent({required this.status});
}
class LoadMoreInvoiceEvent extends InvoiceEvent {
final int status;
const LoadMoreInvoiceEvent({required this.status});
}

View File

@@ -0,0 +1,47 @@
import 'package:krow/features/invoice/domain/invoice_entity.dart';
class InvoicesState {
final bool inLoading;
final int tabIndex;
final Map<int, InvoiceTabState> tabs;
const InvoicesState(
{this.inLoading = false, this.tabIndex = 0, required this.tabs});
InvoicesState copyWith({
bool? inLoading,
int? tabIndex,
Map<int, InvoiceTabState>? tabs,
}) {
return InvoicesState(
inLoading: inLoading ?? this.inLoading,
tabIndex: tabIndex ?? this.tabIndex,
tabs: tabs ?? this.tabs,
);
}
}
class InvoiceTabState {
final List<InvoiceListEntity> items;
final bool inLoading;
final bool hasMoreItems;
const InvoiceTabState({
required this.items,
this.inLoading = false,
this.hasMoreItems = true,
});
InvoiceTabState copyWith({
List<InvoiceListEntity>? items,
bool? inLoading,
bool? hasMoreItems,
}) {
return InvoiceTabState(
items: items ?? this.items,
inLoading: inLoading ?? this.inLoading,
hasMoreItems: hasMoreItems ?? this.hasMoreItems,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:krow/features/invoice/data/models/invoice_model.dart';
class InvoiceListEntity {
final String id;
final InvoiceStatus status;
String? cursor;
final String invoiceNumber;
final String eventName;
final int count;
final double value;
final DateTime date;
final DateTime dueAt;
final String? poNumber;
final InvoiceModel? invoiceModel;
InvoiceListEntity({
required this.id,
required this.status,
required this.invoiceNumber,
required this.eventName,
required this.count,
required this.value,
required this.date,
required this.dueAt,
this.poNumber,
this.cursor,
this.invoiceModel,
});
InvoiceListEntity copyWith({
String? id,
String? cursor,
InvoiceStatus? status,
String? invoiceNumber,
String? eventName,
int? count,
double? value,
DateTime? date,
DateTime? dueAt,
String? poNumber,
}) {
return InvoiceListEntity(
id: id ?? this.id,
cursor: cursor ?? this.cursor,
status: status ?? this.status,
invoiceNumber: invoiceNumber ?? this.invoiceNumber,
eventName: eventName ?? this.eventName,
count: count ?? this.count,
value: value ?? this.value,
date: date ?? this.date,
dueAt: date ?? this.dueAt,
);
}
InvoiceListEntity.fromModel(InvoiceModel model, {this.cursor})
: id = model.id,
poNumber = model.event?.purchaseOrder,
status = model.status,
invoiceNumber = model.event?.id ?? '',
eventName = model.event!.name,
count = model.items?.length?? 0,
value = (model.total ?? 0.0),
date = DateTime.parse(model.event?.date ?? ''),
dueAt = DateTime.parse(model.dueAt ?? ''),
invoiceModel = model;
InvoiceListEntity.empty()
: id = 'INV-20250113-12345',
status = InvoiceStatus.open,
invoiceNumber = 'INV-20250113-12345',
eventName = 'Event Name',
count = 16,
value = 1230,
poNumber = 'PO-123456',
date = DateTime.now(),
dueAt = DateTime.now(),
invoiceModel = null;
}

View File

@@ -0,0 +1,28 @@
import 'package:krow/features/invoice/domain/invoice_entity.dart';
enum InvoiceStatusFilterType {
all,
open,
disputed,
resolved,
paid,
overdue,
verified
}
abstract class InvoicesRepository {
Stream<dynamic> get statusStream;
Future<List<InvoiceListEntity>> getInvoices(
{String? lastItemId, required InvoiceStatusFilterType statusFilter});
void dispose();
Future<void> approveInvoice({required String invoiceId});
Future<void> disputeInvoice({
required String invoiceId,
required String reason,
required String comment,
});
}

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/invoice/domain/blocs/invoices_list_bloc/invoice_bloc.dart';
import 'domain/blocs/invoices_list_bloc/invoice_event.dart';
@RoutePage()
class InvoiceFlowScreen extends StatelessWidget {
const InvoiceFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => InvoiceBloc()..add(const InvoiceInitialEvent()),
child: AutoRouter(),
);
}
}

View File

@@ -0,0 +1,113 @@
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/core/presentation/widgets/ui_kit/kw_button.dart';
class DisputeInfoDialog extends StatefulWidget {
final String reason;
final String details;
final String? supportNote;
const DisputeInfoDialog(
{super.key,
required this.reason,
required this.details,
required this.supportNote});
static Future<Map<String, dynamic>?> showCustomDialog(BuildContext context,
{required String reason,
required String details,
required String? supportNote}) async {
return await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => DisputeInfoDialog(
reason: reason, details: details, supportNote: supportNote),
);
}
@override
State<DisputeInfoDialog> createState() => _DisputeInfoDialogState();
}
class _DisputeInfoDialogState extends State<DisputeInfoDialog> {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
decoration: KwBoxDecorations.white24,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Dispute Details',
style: AppTextStyles.headingH3,
)
],
),
Gap(24),
Text(
'The reason why the invoice is being disputed:',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
Gap(2),
Text(
widget.reason,
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.blackGray),
),
Divider(
color: AppColors.grayTintStroke,
height: 24,
),
Text('Status',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray)),
Gap(2),
Text('Under Review',
style: AppTextStyles.bodyMediumMed
.copyWith(color: AppColors.primaryBlue)),
Gap(12),
Text(
'Additional Details',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
Gap(2),
Text(
widget.details,
style: AppTextStyles.bodyMediumMed,
),
if (widget.supportNote?.isNotEmpty ?? false) ...[
const Gap(12),
Text(
'Support Note',
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
Gap(2),
Text(
widget.supportNote ?? '',
style: AppTextStyles.bodyMediumMed,
),
],
const Gap(24),
KwButton.primary(
label: 'Back to Invoice',
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:auto_route/auto_route.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/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/dialogs/invoice_reason_dropdown.dart';
class ShiftDeclineDialog extends StatefulWidget {
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
State<ShiftDeclineDialog> createState() => _ShiftDeclineDialogState();
}
class _ShiftDeclineDialogState extends State<ShiftDeclineDialog> {
String selectedReason = '';
final _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: KwBoxDecorations.white24,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 64,
width: 64,
decoration: BoxDecoration(
color: AppColors.tintRed,
shape: BoxShape.circle,
),
child: Center(
child: Assets.images.icons.receiptSearch.svg(
width: 32,
height: 32,
colorFilter: ColorFilter.mode(
AppColors.statusError, BlendMode.srcIn),
),
),
),
Gap(32),
Text(
'Dispute Invoice',
style: AppTextStyles.headingH1.copyWith(height: 1),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
'If theres an issue with this invoice, please select a reason below and provide any additional details. Well review your dispute and get back to you as soon as possible.',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
textAlign: TextAlign.center,
),
const Gap(8),
InvoiceReasonDropdown(
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',
hintText: 'Enter your main text here...',
onChanged: (String value) {
setState(() {});
},
),
const Gap(24),
_buttonGroup(context),
],
),
),
),
),
),
);
}
Widget _buttonGroup(
BuildContext context,
) {
return Column(
children: [
KwButton.primary(
disabled: selectedReason.isEmpty || (_textEditingController.text.isEmpty),
label: 'Submit Request',
onPressed: () {
context.pop({
'reason': selectedReason,
'comment': _textEditingController.text,
});
},
),
const Gap(8),
KwButton.outlinedPrimary(
label: 'Cancel',
onPressed: () {
context.pop();
},
),
],
);
}
}

View File

@@ -0,0 +1,113 @@
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 = [
'Hours didnt Match',
'Calculation Issue',
'Other (Please specify)',
];
class InvoiceReasonDropdown extends StatelessWidget {
final String? selectedReason;
final Function(String reason) onReasonSelected;
const InvoiceReasonDropdown(
{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(
'Select reason',
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
.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(
selectedReason ?? 'Select reason from a list',
style: AppTextStyles.bodyMediumReg.copyWith(
color: selectedReason == null
? AppColors.blackGray
: AppColors.blackBlack),
)),
],
),
);
}
KwPopupMenuItem _buildMenuItem(
BuildContext context, String reason, String selectedReason) {
return KwPopupMenuItem(
title: reason,
onTap: () {
onReasonSelected(reason);
},
icon: Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: selectedReason != reason ? null : AppColors.bgColorDark,
shape: BoxShape.circle,
border: selectedReason == reason
? null
: Border.all(color: AppColors.grayTintStroke, width: 1),
),
child: selectedReason == reason
? Center(
child: Assets.images.icons.receiptSearch.svg(
height: 10,
width: 10,
colorFilter: const ColorFilter.mode(
AppColors.grayWhite, BlendMode.srcIn),
))
: null,
),
textStyle: AppTextStyles.bodySmallMed,
);
}
}

View File

@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:krow/core/entity/staff_contact_entity.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/widgets/staff_position_details_widget.dart';
import '../../../../../../core/presentation/styles/theme.dart';
class StaffInvoiceContactInfoPopup {
static Future<void> show(
BuildContext context,
StaffContact staff, {
String? date,
required double hours,
required double subtotal,
}) async {
return showDialog<void>(
context: context,
builder: (context) {
return Center(
child: _StaffPopupWidget(
staff,
date: date,
hours: hours,
subtotal: subtotal,
),
);
});
}
}
class _StaffPopupWidget extends StatelessWidget {
final StaffContact staff;
final String? date;
final double hours;
final double subtotal;
const _StaffPopupWidget(this.staff,
{this.date, required this.hours, required this.subtotal});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
// margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: KwBoxDecorations.white24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _ongoingBtn(context),
),
),
);
}
List<Widget> _ongoingBtn(BuildContext context) {
return [
const Gap(32),
StaffPositionAvatar(
imageUrl: staff.photoUrl,
userName: '${staff.firstName} ${staff.lastName}',
status: staff.status,
),
const Gap(16),
StaffContactsWidget(staff: staff),
StaffPositionDetailsWidget(
staff: staff, date: date, hours: hours, subtotal: subtotal),
const Gap(12),
];
}
}
class StaffPositionDetailsWidget extends StatelessWidget {
final StaffContact staff;
final String? date;
final double hours;
final double subtotal;
const StaffPositionDetailsWidget({
super.key,
required this.staff,
this.date,
required this.hours,
required this.subtotal,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight8,
child: Column(
children: [
_textRow('Date',
DateFormat('MM.dd.yyyy').format(DateTime.parse(date ?? ''))),
_textRow(
'Start Time',
DateFormat('hh:mm a').format(
DateFormat('yyyy-MM-dd hh:mm:ss').parse(staff.startAt))),
_textRow(
'End Time',
DateFormat('hh:mm a').format(
DateFormat('yyyy-MM-dd hh:mm:ss').parse(staff.endAt))),
if (staff.breakIn.isNotEmpty && staff.breakOut.isNotEmpty)
_textRow(
'Break',
DateTime.parse(staff.breakIn)
.difference(DateTime.parse(staff.breakOut))
.inMinutes
.toString() +
' minutes'),
_textRow('Rate (\$/h)', '\$${staff.rate.toStringAsFixed(2)}/h'),
_textRow('Hours', '${hours.toStringAsFixed(2)}'),
_textRow('Subtotal', '\$${subtotal.toStringAsFixed(2)}'),
],
),
);
}
Widget _textRow(String title, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
),
Text(
value,
style: AppTextStyles.bodySmallMed,
),
],
),
);
}
}

View File

@@ -0,0 +1,139 @@
import 'package:auto_route/auto_route.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/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/invoice/data/models/invoice_decline_model.dart';
import 'package:krow/features/invoice/data/models/invoice_model.dart';
import 'package:krow/features/invoice/domain/blocs/invoice_details_bloc/invoice_details_bloc.dart';
import 'package:krow/features/invoice/domain/invoice_entity.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/dialogs/dispute_info_dialog.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/dialogs/invoice_dispute_dialog.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/widgets/invoice_details_widget.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/widgets/invoice_from_to_widget.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/widgets/invoice_info_card.dart';
import 'package:krow/features/invoice/presentation/screens/invoice_details/widgets/invoice_total_widget.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
@RoutePage()
class InvoiceDetailsScreen extends StatelessWidget implements AutoRouteWrapper {
final InvoiceListEntity invoice;
const InvoiceDetailsScreen({required this.invoice, super.key});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => InvoiceDetailsBloc(invoice.invoiceModel!),
child: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: KwAppBar(
titleText: 'Invoice Details',
),
body: BlocConsumer<InvoiceDetailsBloc, InvoiceDetailsState>(
listener: (context, state) {
if (state.showErrorPopup != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.showErrorPopup ?? ''),
));
} else if (state.success) {
context.router.maybePop();
}
},
builder: (context, state) {
return ModalProgressHUD(
inAsyncCall: state.inLoading,
child: ScrollLayoutHelper(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24),
upperWidget: Column(
children: [
InvoiceInfoCardWidget(item: state.invoiceModel??invoice.invoiceModel!),
Gap(12),
InvoiceFromToWidget(item: state.invoiceModel??invoice.invoiceModel!),
Gap(12),
InvoiceDetailsWidget(invoice: state.invoiceModel??invoice.invoiceModel!),
Gap(12),
InvoiceTotalWidget(),
Gap(24),
],
),
lowerWidget: _buildButtons(context, state),
),
);
},
),
);
}
_buildButtons(BuildContext context, InvoiceDetailsState state) {
return Column(
children: [
if (invoice.invoiceModel?.status == InvoiceStatus.open) ...[
KwButton.primary(
label: 'Approve Invoice',
onPressed: () {
context.read<InvoiceDetailsBloc>().add(InvoiceApproveEvent());
},
),
Gap(8),
KwButton.outlinedPrimary(
label: 'Dispute Invoice',
onPressed: () async {
var result = await ShiftDeclineDialog.showCustomDialog(context);
print(result);
if (result != null) {
context.read<InvoiceDetailsBloc>().add(InvoiceDisputeEvent(
reason: result['reason'], comment: result['comment']));
KwDialog.show(
icon: Assets.images.icons.receiptSearch,
state: KwDialogState.negative,
context: context,
title: 'Request is \nUnder Review',
message:
'Thank you! Your request for invoice issue, it is now under review. You will be notified of the outcome shortly.',
primaryButtonLabel: 'Back to Event',
onPrimaryButtonPressed: (dialogContext) {
dialogContext.pop();
},
secondaryButtonLabel: 'Cancel',
onSecondaryButtonPressed: (dialogContext) {
dialogContext.pop();
context.router.maybePop();
},
);
}
},
).copyWith(
color: AppColors.statusError, borderColor: AppColors.statusError),
],
if (invoice.invoiceModel?.status == InvoiceStatus.disputed) ...[
_buildViewDispute(context, state.invoiceModel?.dispute),
],
],
);
}
_buildViewDispute(BuildContext context, InvoiceDeclineModel? dispute) {
return KwButton.primary(
label: 'View Dispute',
onPressed: () {
DisputeInfoDialog.showCustomDialog(
context,
reason: dispute?.reason ?? '',
details: dispute?.details ?? '',
supportNote: dispute?.supportNote,
);
},
);
}
}

View File

@@ -0,0 +1,384 @@
import 'dart:math';
import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/models/shift/event_shift_position_model.dart';
import 'package:krow/core/entity/staff_contact_entity.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/invoice/data/models/invoice_item_model.dart';
import 'package:krow/features/invoice/data/models/invoice_model.dart';
import '../../../../../../core/data/models/staff/pivot.dart';
import '../../../../../../core/entity/position_entity.dart';
import '../dialogs/staff_contact_info_popup.dart';
class InvoiceDetailsWidget extends StatefulWidget {
final InvoiceModel invoice;
const InvoiceDetailsWidget({super.key, required this.invoice});
@override
State<InvoiceDetailsWidget> createState() => _InvoiceDetailsWidgetState();
}
class _InvoiceDetailsWidgetState extends State<InvoiceDetailsWidget> {
List<ExpandableController> controllers = [];
ExpandableController showMoreController = ExpandableController();
Map<EventShiftPositionModel, List<InvoiceItemModel>> staffByPosition = {};
@override
void initState() {
staffByPosition = groupByPosition(widget.invoice.items ?? []);
controllers =
List.generate(staffByPosition.length, (_) => ExpandableController());
super.initState();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Invoice details',
style: AppTextStyles.headingH3,
),
const SizedBox(height: 12),
Container(
decoration: KwBoxDecorations.white12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTable(),
],
),
),
],
);
}
Widget buildTable() {
final columnWidths = {
0: const FlexColumnWidth(),
1: const IntrinsicColumnWidth(),
2: const IntrinsicColumnWidth(),
3: const IntrinsicColumnWidth(),
};
final rows = <TableRow>[];
_buildTableHeader(rows);
var maxVisibleRows = staffByPosition.length;
for (int index = 0; index < maxVisibleRows; index++) {
final position = staffByPosition.keys.elementAt(index);
final group = staffByPosition[position]!;
final controller = controllers[index];
final roleDuration =
group.fold(0.0, (sum, item) => sum + (item.workHours ?? 0));
final subTotal =
group.fold(0.0, (sum, item) => sum + (item.totalAmount ?? 0));
_buildRoleRows(rows, controller, position, roleDuration, subTotal,
isLastRow: index == maxVisibleRows - 1);
_buildStaffsRows(controller, group, rows, position);
}
return AnimatedSize(
alignment: Alignment.topCenter,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
child: Table(
columnWidths: columnWidths,
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: rows,
),
);
}
void _buildTableHeader(List<TableRow> rows) {
return rows.add(
TableRow(
children: buildRowCells(
[
Padding(
padding: const EdgeInsets.only(left: 22, top: 12, bottom: 12),
child: Text('Role', style: AppTextStyles.bodyMediumMed),
),
Padding(
padding: const EdgeInsets.only(
left: 8, right: 16, top: 12, bottom: 12),
child: Text('Rate (\$/h)', style: AppTextStyles.bodyMediumMed),
),
Padding(
padding: const EdgeInsets.only(
left: 8, right: 16, top: 12, bottom: 12),
child: Text('Hours', style: AppTextStyles.bodyMediumMed),
),
Padding(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 12, bottom: 12),
child: Text('Subtotal', style: AppTextStyles.bodyMediumMed),
),
],
),
),
);
}
void _buildRoleRows(List<TableRow> rows, ExpandableController controller,
EventShiftPositionModel position, double roleDuration, double subTotal,
{required bool isLastRow}) {
return rows.add(
TableRow(
children: buildRowCells(
isLastRow: isLastRow && !controller.expanded,
[
expandableCell(
controller: controller,
padding: const EdgeInsets.only(left: 6, top: 16, bottom: 13),
child: Row(
children: [
AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: controller.expanded ? -0.5 : 0,
child: Assets.images.icons.chevronDown
.svg(width: 12, height: 12),
),
Gap(4),
Expanded(
child: Text(
position.businessSkill.skill?.name ?? '',
overflow: TextOverflow.ellipsis,
)),
],
),
),
expandableCell(
controller: controller,
padding: const EdgeInsets.only(
left: 12, right: 8, top: 16, bottom: 16),
child: Text(
'\$${position.rate.toStringAsFixed(2)}/h',
style: AppTextStyles.bodyMediumReg,
),
),
expandableCell(
controller: controller,
padding: const EdgeInsets.only(
left: 12, right: 8, top: 16, bottom: 16),
child: Text(roleDuration.toStringAsFixed(1),
style: AppTextStyles.bodyMediumReg),
),
expandableCell(
controller: controller,
padding: const EdgeInsets.only(
left: 12, right: 8, top: 16, bottom: 16),
child: Text('\$${subTotal.toStringAsFixed(2)}',
style: AppTextStyles.bodyMediumReg),
),
],
),
),
);
}
void _buildStaffsRows(
ExpandableController controller,
List<InvoiceItemModel> group,
List<TableRow> rows,
EventShiftPositionModel position) {
if (controller.expanded) {
for (int i = 0; i < group.length; i++) {
final user = group[i];
rows.add(
TableRow(
children: buildRowCells(isLastRow: i == group.length - 1, [
GestureDetector(
onTap: () {
StaffInvoiceContactInfoPopup.show(
context,
makeStaffContact(
user,
position,
),
date: widget.invoice.event?.date,
hours: user.workHours ?? 0.0,
subtotal: user.totalAmount ?? 0.0);
},
child: buildAnimatedStaffCell(
visible: controller.expanded,
child: Padding(
padding: const EdgeInsets.only(left: 12.0),
child: Text(
'${user.staff?.firstName ?? ''} ${user.staff?.lastName ?? ''}',
style: AppTextStyles.bodyTinyReg.copyWith(
color: AppColors.blackGray,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
buildAnimatedStaffCell(
visible: controller.expanded,
child: Text('\$${(user.rate?.toStringAsFixed(2)) ?? 0}/h',
style: AppTextStyles.bodyTinyReg),
),
buildAnimatedStaffCell(
visible: controller.expanded,
child: Text('${user.workHours?.toStringAsFixed(1) ?? 0}',
style: AppTextStyles.bodyTinyReg),
),
buildAnimatedStaffCell(
visible: controller.expanded,
child: Text('\$${user.totalAmount ?? 0}',
style: AppTextStyles.bodyTinyReg),
),
]),
),
);
}
}
}
List<Widget> buildRowCells(List<Widget> cells, {bool isLastRow = false}) {
return List.generate(cells.length, (i) {
final isLastColumn = i == cells.length - 1;
return buildGridCell(
child: cells[i],
showRight: !isLastColumn,
showBottom: !isLastRow,
);
});
}
Widget expandableCell(
{required Widget child,
required ExpandableController controller,
required EdgeInsets padding}) {
return GestureDetector(
onTap: () {
setState(() {
controller.expanded = !controller.expanded;
});
},
child: Container(
color: Colors.transparent, padding: padding, child: child));
}
Widget buildGridCell({
required Widget child,
bool showRight = true,
bool showBottom = true,
EdgeInsets padding = const EdgeInsets.all(8),
}) {
return Container(
padding: padding,
decoration: BoxDecoration(
border: Border(
right: showRight
? BorderSide(color: Colors.grey.shade300, width: 1)
: BorderSide.none,
bottom: showBottom
? BorderSide(color: Colors.grey.shade300, width: 1)
: BorderSide.none,
),
),
child: child,
);
}
Widget buildAnimatedStaffCell({
required bool visible,
required Widget child,
Duration duration = const Duration(milliseconds: 300),
Curve curve = Curves.easeInOut,
}) {
return AnimatedContainer(
duration: duration,
curve: curve,
color: Colors.transparent,
height: visible ? null : 0,
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 6, top: 10, bottom: 10),
child: AnimatedOpacity(
duration: duration,
opacity: visible ? 1 : 0,
child: visible ? child : IgnorePointer(child: child),
),
),
);
}
Map<EventShiftPositionModel, List<InvoiceItemModel>> groupByPosition(
List<InvoiceItemModel> items,
) {
final Map<String, EventShiftPositionModel> positionCache = {};
final Map<EventShiftPositionModel, List<InvoiceItemModel>> grouped = {};
for (final item in items) {
final position = item.position;
if (position == null) continue;
final id = position.businessSkill.id ?? '';
final existingPosition = positionCache[id];
final key = existingPosition ??
() {
positionCache[id] = position;
return position;
}();
grouped.putIfAbsent(key, () => []);
grouped[key]!.add(item);
}
return grouped;
}
StaffContact makeStaffContact(
InvoiceItemModel user, EventShiftPositionModel position) {
var staff = user.staff!;
var pivot = user.position!.staff!
.firstWhere(
(element) => element.id == user.staff?.id,
)
.pivot;
return StaffContact(
id: staff.id ?? '',
photoUrl: staff.avatar ?? '',
firstName: staff.firstName ?? '',
lastName: staff.lastName ?? '',
phoneNumber: staff.phone ?? '',
email: staff.email ?? '',
rate: position.businessSkill.price ?? 0,
status: pivot?.status ?? PivotStatus.assigned,
startAt: pivot?.clockIn ?? '',
endAt: pivot?.clockOut ?? '',
breakIn: pivot?.breakIn ?? '',
breakOut: pivot?.breakOut ?? '',
parentPosition: PositionEntity.fromDto(
position,
DateTime.tryParse(widget.invoice.event?.date ?? '') ??
DateTime.now()),
skillName: position.businessSkill.skill?.name ?? '',
);
}
}

View File

@@ -0,0 +1,148 @@
import 'package:expandable/expandable.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_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/invoice/data/models/invoice_model.dart';
import 'package:krow/features/invoice/domain/blocs/invoice_details_bloc/invoice_details_bloc.dart';
import 'package:krow/features/invoice/domain/invoice_entity.dart';
class InvoiceFromToWidget extends StatefulWidget {
final InvoiceModel item;
const InvoiceFromToWidget({super.key, required this.item});
@override
State<InvoiceFromToWidget> createState() => _InvoiceFromToWidgetState();
}
class _InvoiceFromToWidgetState extends State<InvoiceFromToWidget> {
ExpandableController? _expandableFromController;
ExpandableController? _expandableToController;
@override
void initState() {
super.initState();
_expandableFromController = ExpandableController(initialExpanded: true);
_expandableToController = ExpandableController(initialExpanded: true);
}
@override
void dispose() {
super.dispose();
_expandableFromController?.dispose();
_expandableToController?.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<InvoiceDetailsBloc, InvoiceDetailsState>(
builder: (context, state) {
return ExpandableTheme(
data: ExpandableThemeData(
headerAlignment: ExpandablePanelHeaderAlignment.center,
iconPadding: const EdgeInsets.only(left: 12),
),
child: Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Gap(12),
ExpandablePanel(
collapsed: Container(),
controller: _expandableFromController,
expanded: _fromInfo(),
header: _buildHeader(
context, _expandableFromController, 'From: Legendary'),
),
ExpandablePanel(
collapsed: Container(),
controller: _expandableToController,
expanded: _toInfo(state.invoiceModel!),
header: _buildHeader(
context, _expandableToController, 'To: ${state.invoiceModel?.business?.name}'),
),
Gap(12),
],
),
),
);
},
);
}
Widget _buildHeader(BuildContext context, ExpandableController? controller,
String title,) {
return GestureDetector(
onTap: () {
controller?.toggle();
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
color: Colors.transparent,
child: Text(
title,
style: AppTextStyles.headingH3,
),
),
);
}
Widget _fromInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(4),
infoElement('Address', '848 E Gish Rd, Suite 1 San Jose, CA 95112'),
Gap(12),
infoElement('Phone Number', '4088360180'),
Gap(12),
infoElement('Email', 'orders@legendaryeventstaff.com'),
Gap(20),
],
);
}
Widget _toInfo(InvoiceModel invoice) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(4),
infoElement('Hub', invoice.event?.hub?.name ?? ''),
Gap(12),
infoElement('Address', invoice.business?.registration ?? ''),
Gap(12),
infoElement('Full Name', '${invoice.business?.contact?.firstName??''} ${invoice.business?.contact?.lastName??''}' ),
Gap(12),
infoElement('Phone Number', invoice.business?.contact?.phone ?? ''),
Gap(12),
infoElement('Email', invoice.business?.contact?.email ?? ''),
Gap(12),
],
);
}
Widget infoElement(String title, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style:
AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
),
Gap(2),
Text(
value,
style: AppTextStyles.bodyMediumMed,
)
],
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/application/common/str_extensions.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/entity/event_entity.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/icon_row_info_widget.dart';
import 'package:krow/features/invoice/data/models/invoice_model.dart';
import 'package:krow/features/invoice/domain/invoice_entity.dart';
class InvoiceInfoCardWidget extends StatelessWidget {
final InvoiceModel item;
const InvoiceInfoCardWidget({
required this.item,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusRow(),
const Gap(24),
Text('INV-${item.id}', style: AppTextStyles.headingH1),
Gap(4),
GestureDetector(
onTap: () {
if(item.event == null) return;
context.router.push(EventDetailsRoute(event: EventEntity.fromEventDto(item.event!)));
},
child: Text(item.event!.name,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray,decoration: TextDecoration.underline)),
),
const Gap(24),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'Date',
value: DateFormat('MM.dd.yyyy').format(DateTime.parse(item.event?.date ?? '')),
),
const Gap(24),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'Due Date',
value: DateFormat('MM.dd.yyyy').format(DateTime.parse(item.dueAt ?? '')),
),
if (item.event?.purchaseOrder != null) ...[
const Gap(24),
IconRowInfoWidget(
icon: Assets.images.icons.calendar.svg(),
title: 'PO Number',
value: item.event!.purchaseOrder!,
),
],
],
),
);
}
Row _buildStatusRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 48,
width: 48,
decoration: const BoxDecoration(
color: AppColors.tintGreen,
shape: BoxShape.circle,
),
child: Center(
child: Assets.images.icons.moneySend.svg(
colorFilter: const ColorFilter.mode(
AppColors.statusSuccess, BlendMode.srcIn),
),
),
),
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: item.status.color,
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 2.0),
child: Text(
item.status.name.capitalize(),
style: AppTextStyles.bodySmallMed
.copyWith(color: AppColors.grayWhite),
),
),
))
],
);
}
}
extension on InvoiceStatus {
Color get color {
switch (this) {
case InvoiceStatus.open:
return AppColors.primaryBlue;
case InvoiceStatus.disputed:
return AppColors.statusWarning;
case InvoiceStatus.resolved:
return AppColors.statusSuccess;
case InvoiceStatus.paid:
return AppColors.blackGray;
case InvoiceStatus.overdue:
return AppColors.statusError;
case InvoiceStatus.verified:
return AppColors.bgColorDark;
}
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/invoice/data/models/invoice_model.dart';
import 'package:krow/features/invoice/domain/blocs/invoice_details_bloc/invoice_details_bloc.dart';
import 'package:krow/features/invoice/domain/invoice_entity.dart';
class InvoiceTotalWidget extends StatelessWidget {
const InvoiceTotalWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<InvoiceDetailsBloc, InvoiceDetailsState>(
builder: (context, state) {
return Container(
decoration: KwBoxDecorations.white12,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total', style: AppTextStyles.headingH3),
Gap(24),
Container(
decoration: KwBoxDecorations.primaryLight8,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Column(
children: [
totalInfoRow('Sub Total', '\$${state.invoiceModel?.workAmount?.toStringAsFixed(2)??'0'}'),
Gap(8),
totalInfoRow('Other Charges', '\$${state.invoiceModel?.addonsAmount?.toStringAsFixed(2)??'0'}'),
Gap(8),
totalInfoRow('Grand Total', '\$${state.invoiceModel?.total?.toStringAsFixed(2)??'0'}'),
],
),
)
],
),
);
},
);
}
Widget totalInfoRow(String title, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
Gap(20),
Text(value, style: AppTextStyles.bodyMediumMed)
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More