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

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:krow/core/application/common/str_extensions.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/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 InvoiceListItemWidget extends StatelessWidget {
final InvoiceListEntity item;
const InvoiceListItemWidget({required this.item, super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
context.router.push(InvoiceDetailsRoute(
invoice: 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('INV-${item.id}', style: AppTextStyles.headingH1),
const Gap(4),
Text(item.eventName,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
const Gap(12),
IconRowInfoWidget(
icon: Assets.images.icons.profile2user.svg(),
title: 'Count',
value: '${item.count} persons',
),
const Gap(24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: KwBoxDecorations.primaryLight8.copyWith(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Value',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray)),
Text(
'\$${item.value}',
style: AppTextStyles.bodyMediumMed,
)
],
),
)
],
),
),
);
}
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,146 @@
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/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_tabs.dart';
import 'package:krow/features/invoice/domain/blocs/invoices_list_bloc/invoice_bloc.dart';
import 'package:krow/features/invoice/domain/blocs/invoices_list_bloc/invoice_state.dart';
import 'package:krow/features/invoice/presentation/screens/invoices_list/invoice_list_item.dart';
import '../../../domain/blocs/invoices_list_bloc/invoice_event.dart';
import '../../../domain/invoice_entity.dart';
@RoutePage()
class InvoicesListMainScreen extends StatefulWidget {
const InvoicesListMainScreen({super.key});
@override
State<InvoicesListMainScreen> createState() => _InvoicesListMainScreenState();
}
class _InvoicesListMainScreenState extends State<InvoicesListMainScreen> {
final List<String> tabs = const [
'All',
'Open',
'Disputed',
'Resolved',
'Paid',
'Overdue',
'Verified',
];
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<InvoiceBloc>(context).add(
LoadMoreInvoiceEvent(
status: BlocProvider.of<InvoiceBloc>(context).state.tabIndex),
);
}
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<InvoiceBloc, InvoicesState>(
builder: (context, state) {
List<InvoiceListEntity> items = state.tabs[state.tabIndex]!.items;
return Scaffold(
appBar: KwAppBar(
titleText: 'Invoices',
centerTitle: false,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.symmetric(vertical: 16),
onRefresh: () async {
BlocProvider.of<InvoiceBloc>(context)
.add(LoadTabInvoiceEvent(status: state.tabIndex));
},
controller: _scrollController,
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
KwTabBar(
tabs: tabs,
onTap: (index) {
BlocProvider.of<InvoiceBloc>(context)
.add(InvoiceTabChangedEvent(tabIndex: index));
}),
const Gap(16),
if (state.tabs[state.tabIndex]!.inLoading &&
state.tabs[state.tabIndex]!.items.isEmpty)
..._buildListLoading(),
if (!state.tabs[state.tabIndex]!.inLoading && items.isEmpty)
..._emptyListWidget(),
RefreshIndicator(
onRefresh: () async {
BlocProvider.of<InvoiceBloc>(context)
.add(LoadTabInvoiceEvent(status: state.tabIndex));
},
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) {
return InvoiceListItemWidget(item: items[index]);
}),
),
],
),
lowerWidget: const SizedBox.shrink(),
),
);
},
);
}
List<Widget> _buildListLoading() {
return [
const Gap(116),
const Center(child: CircularProgressIndicator()),
];
}
List<Widget> _emptyListWidget() {
return [
const Gap(100),
Container(
height: 64,
width: 64,
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(32),
),
child: Center(child: Assets.images.icons.xCircle.svg()),
),
const Gap(24),
const Text(
'You currently have no Invoices',
textAlign: TextAlign.center,
style: AppTextStyles.headingH2,
),
];
}
}