feat: introduce completion review UI components for actions, amount, info, search, and worker listing.
This commit is contained in:
@@ -541,8 +541,8 @@
|
||||
"min_break": "min break"
|
||||
},
|
||||
"actions": {
|
||||
"approve_pay": "Approve & Process Payment",
|
||||
"flag_review": "Flag for Review",
|
||||
"approve_pay": "Approve",
|
||||
"flag_review": "Review",
|
||||
"download_pdf": "Download Invoice PDF"
|
||||
},
|
||||
"flag_dialog": {
|
||||
|
||||
@@ -536,8 +536,8 @@
|
||||
"min_break": "min de descanso"
|
||||
},
|
||||
"actions": {
|
||||
"approve_pay": "Aprobar y Procesar Pago",
|
||||
"flag_review": "Marcar para Revisi\u00f3n",
|
||||
"approve_pay": "Aprobar",
|
||||
"flag_review": "Revisi\u00f3n",
|
||||
"download_pdf": "Descargar PDF de Factura"
|
||||
},
|
||||
"flag_dialog": {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
|
||||
import '../widgets/completion_review/completion_review_actions.dart';
|
||||
import '../widgets/completion_review/completion_review_amount.dart';
|
||||
import '../widgets/completion_review/completion_review_info.dart';
|
||||
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||
|
||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||
|
||||
@@ -26,7 +30,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use widget.invoice if provided, else try to get from arguments
|
||||
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice;
|
||||
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,390 +50,45 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
showBackButton: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildInvoiceInfoCard(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildAmountCard(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildWorkersHeader(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildSearchAndTabs(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...filteredWorkers.map(
|
||||
(BillingWorkerRecord worker) => _buildWorkerCard(worker),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildActionButtons(context),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDownloadLink(),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvoiceInfoCard() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||
_buildInfoRow(
|
||||
UiIcons.clock,
|
||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||
),
|
||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(text, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: const Color(0xFFDBEAFE)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_billing.total_amount_label,
|
||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||
style: UiTypography.footnote2b.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkersHeader() {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.workers_tab.title(count: invoice.workersCount),
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchAndTabs() {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (String val) => setState(() => searchQuery = val),
|
||||
decoration: InputDecoration(
|
||||
icon: const Icon(
|
||||
UiIcons.search,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
hintText: t.client_billing.workers_tab.search_hint,
|
||||
hintStyle: UiTypography.body2r.textSecondary,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
t.client_billing.workers_tab.needs_review(count: 0),
|
||||
0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
t.client_billing.workers_tab.all(count: invoice.workersCount),
|
||||
1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String text, int index) {
|
||||
final bool isSelected = selectedTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => selectedTab = index),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkerCard(BillingWorkerRecord worker) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
backgroundImage: worker.workerAvatarUrl != null
|
||||
? NetworkImage(worker.workerAvatarUrl!)
|
||||
: null,
|
||||
child: worker.workerAvatarUrl == null
|
||||
? const Icon(
|
||||
UiIcons.user,
|
||||
size: 20,
|
||||
color: UiColors.iconSecondary,
|
||||
)
|
||||
: null,
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewInfo(invoice: invoice),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewAmount(invoice: invoice),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewSearchAndTabs(
|
||||
selectedTab: selectedTab,
|
||||
workersCount: invoice.workersCount,
|
||||
onTabChanged: (int index) =>
|
||||
setState(() => selectedTab = index),
|
||||
onSearchChanged: (String val) =>
|
||||
setState(() => searchQuery = val),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.workerName,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
worker.roleName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...filteredWorkers.map(
|
||||
(BillingWorkerRecord worker) =>
|
||||
CompletionReviewWorkerCard(worker: worker),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${worker.startTime} - ${worker.endTime}',
|
||||
style: UiTypography.footnote2b.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.coffee,
|
||||
size: 12,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: UiIcons.checkCircle,
|
||||
onPressed: () {
|
||||
Modular.get<BillingBloc>().add(
|
||||
BillingInvoiceApproved(invoice.id),
|
||||
);
|
||||
Modular.to.pop();
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.client_billing.approved_success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF22C55E),
|
||||
foregroundColor: Colors.white,
|
||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: Colors.orange, width: 2),
|
||||
),
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: () => _showFlagDialog(context),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.orange,
|
||||
side: BorderSide.none,
|
||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadLink() {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
|
||||
label: Text(
|
||||
t.client_billing.actions.download_pdf,
|
||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlagDialog(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.client_billing.flag_dialog.title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.client_billing.flag_dialog.hint,
|
||||
bottomNavigationBar: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (controller.text.isNotEmpty) {
|
||||
Modular.get<BillingBloc>().add(
|
||||
BillingInvoiceDisputed(invoice.id, controller.text),
|
||||
);
|
||||
Navigator.pop(dialogContext);
|
||||
Modular.to.pop();
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.client_billing.flagged_success,
|
||||
type: UiSnackbarType.warning,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(t.client_billing.flag_dialog.button),
|
||||
),
|
||||
],
|
||||
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class InvoiceReadyPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<BillingBloc>.value(
|
||||
value: Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
|
||||
child: const Placeholder(),
|
||||
child: const InvoiceReadyView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true),
|
||||
body: BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (context, state) {
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -56,9 +56,10 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
itemCount: state.invoiceHistory.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final invoice = state.invoiceHistory[index];
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(height: 16),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||
return _InvoiceSummaryCard(invoice: invoice);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../../blocs/billing_bloc.dart';
|
||||
import '../../blocs/billing_event.dart';
|
||||
|
||||
class CompletionReviewActions extends StatelessWidget {
|
||||
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||
|
||||
final String invoiceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: () => _showFlagDialog(context),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: UiIcons.checkCircle,
|
||||
onPressed: () {
|
||||
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoiceId));
|
||||
Modular.to.pop();
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.client_billing.approved_success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlagDialog(BuildContext context) {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => AlertDialog(
|
||||
title: Text(t.client_billing.flag_dialog.title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.client_billing.flag_dialog.hint,
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (controller.text.isNotEmpty) {
|
||||
Modular.get<BillingBloc>().add(
|
||||
BillingInvoiceDisputed(invoiceId, controller.text),
|
||||
);
|
||||
Navigator.pop(dialogContext);
|
||||
Modular.to.pop();
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.client_billing.flagged_success,
|
||||
type: UiSnackbarType.warning,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(t.client_billing.flag_dialog.button),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
class CompletionReviewAmount extends StatelessWidget {
|
||||
const CompletionReviewAmount({required this.invoice, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: const Color(0xFFDBEAFE)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_billing.total_amount_label,
|
||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||
style: UiTypography.footnote2b.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
class CompletionReviewInfo extends StatelessWidget {
|
||||
const CompletionReviewInfo({required this.invoice, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||
_buildInfoRow(
|
||||
UiIcons.clock,
|
||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||
),
|
||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(text, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||
const CompletionReviewSearchAndTabs({
|
||||
required this.selectedTab,
|
||||
required this.onTabChanged,
|
||||
required this.onSearchChanged,
|
||||
required this.workersCount,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final int selectedTab;
|
||||
final ValueChanged<int> onTabChanged;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final int workersCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
icon: const Icon(
|
||||
UiIcons.search,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
hintText: t.client_billing.workers_tab.search_hint,
|
||||
hintStyle: UiTypography.body2r.textSecondary,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
t.client_billing.workers_tab.needs_review(count: 0),
|
||||
0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
t.client_billing.workers_tab.all(count: workersCount),
|
||||
1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String text, int index) {
|
||||
final bool isSelected = selectedTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => onTabChanged(index),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||
const CompletionReviewWorkerCard({required this.worker, super.key});
|
||||
|
||||
final BillingWorkerRecord worker;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
backgroundImage: worker.workerAvatarUrl != null
|
||||
? NetworkImage(worker.workerAvatarUrl!)
|
||||
: null,
|
||||
child: worker.workerAvatarUrl == null
|
||||
? const Icon(
|
||||
UiIcons.user,
|
||||
size: 20,
|
||||
color: UiColors.iconSecondary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.workerName,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
worker.roleName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${worker.startTime} - ${worker.endTime}',
|
||||
style: UiTypography.footnote2b.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.coffee,
|
||||
size: 12,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CompletionReviewWorkersHeader extends StatelessWidget {
|
||||
const CompletionReviewWorkersHeader({required this.workersCount, super.key});
|
||||
|
||||
final int workersCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.workers_tab.title(count: workersCount),
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user