feat: Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 there’s an issue with this invoice, please select a reason below and provide any additional details. We’ll 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 didn’t 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user