824 lines
27 KiB
Dart
824 lines
27 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../../core/router/app_router.dart';
|
||
import '../../../core/theme/app_colors.dart';
|
||
import '../../../core/theme/app_dimens.dart';
|
||
import '../../../core/theme/app_typography.dart';
|
||
import '../../../core/utils/formatters.dart';
|
||
import '../../../core/widgets/primary_button.dart';
|
||
import '../../../domain/entities/shift_report.dart';
|
||
import '../../../domain/entities/transaction.dart';
|
||
import '../../../domain/repositories/sync_repository.dart';
|
||
import '../../auth/providers/auth_controller.dart';
|
||
import '../../payment/screens/payment_screen.dart' show methodIcon;
|
||
import '../../pos/providers/cart_controller.dart';
|
||
import '../../pos/providers/catalog_providers.dart';
|
||
import '../../pos/providers/navigation_provider.dart';
|
||
import '../../sync/providers/sync_controller.dart';
|
||
|
||
/// Closing the till.
|
||
///
|
||
/// Three things have to happen at the end of a shift and they have to happen
|
||
/// in this order: count what is physically in the drawer, compare it against
|
||
/// what the terminal says was taken in cash, then push the day up and hand the
|
||
/// terminal back. Doing it as a dialog meant the count was a single guessed
|
||
/// number typed into a box; a shift is worth its own screen.
|
||
///
|
||
/// The variance is the whole point. A till that is short is worth knowing
|
||
/// about while the person who worked it is still standing there.
|
||
class EndShiftScreen extends ConsumerStatefulWidget {
|
||
const EndShiftScreen({super.key});
|
||
|
||
/// Below this the two columns stack.
|
||
static const double twoColumnAbove = 1000;
|
||
|
||
@override
|
||
ConsumerState<EndShiftScreen> createState() => _EndShiftScreenState();
|
||
}
|
||
|
||
/// What a till drawer actually holds, largest first.
|
||
const _denominations = <int>[2000, 500, 200, 100, 50, 20, 10, 5, 2, 1];
|
||
|
||
class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
|
||
/// Note or coin value → how many were counted.
|
||
final Map<int, int> _counted = {};
|
||
|
||
final _openingFloat = TextEditingController(text: '0');
|
||
bool _pushing = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void dispose() {
|
||
_openingFloat.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
double get _countedTotal => _counted.entries
|
||
.fold(0.0, (sum, e) => sum + e.key * e.value);
|
||
|
||
double get _float => double.tryParse(_openingFloat.text.trim()) ?? 0;
|
||
|
||
int get _noteCount => _counted.values.fold(0, (sum, n) => sum + n);
|
||
|
||
void _set(int denomination, int count) {
|
||
setState(() {
|
||
if (count <= 0) {
|
||
_counted.remove(denomination);
|
||
} else {
|
||
_counted[denomination] = count;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Cash the terminal believes was taken, from the tender records — not from
|
||
/// the bill totals, which include card and UPI.
|
||
double _cashTaken(ShiftReport? report) =>
|
||
report?.paymentBreakdown[PaymentMethod.cash] ?? 0;
|
||
|
||
/// Pushes what is still held, then ends the session and clears the terminal.
|
||
Future<void> _finish({required bool sync}) async {
|
||
setState(() {
|
||
_pushing = true;
|
||
_error = null;
|
||
});
|
||
|
||
if (sync) {
|
||
final outcome = await ref.read(orderSyncProvider.notifier).run();
|
||
if (!mounted) return;
|
||
if (!outcome.isSuccess) {
|
||
setState(() {
|
||
_pushing = false;
|
||
_error = outcome.error ??
|
||
'Upload failed. Every bill is still stored on this terminal.';
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
ref.read(cartControllerProvider.notifier).reset();
|
||
|
||
// The real end of shift: the catalogue goes with it, so the next person
|
||
// bills against a fresh import rather than this morning's prices.
|
||
await ref.read(authControllerProvider.notifier).signOut();
|
||
|
||
ref.read(catalogueVersionProvider.notifier).state++;
|
||
ref.invalidate(allProductsProvider);
|
||
ref.invalidate(visibleProductsProvider);
|
||
ref.invalidate(categoryCountsProvider);
|
||
ref.invalidate(lowStockProductsProvider);
|
||
|
||
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
|
||
ref.read(searchQueryProvider.notifier).state = '';
|
||
ref.read(selectedCategoryProvider.notifier).state = null;
|
||
|
||
if (!mounted) return;
|
||
context.go(AppRoutes.login);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final report = ref.watch(myShiftReportProvider).value;
|
||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||
final user = ref.watch(currentUserProvider);
|
||
|
||
final cashTaken = _cashTaken(report);
|
||
final expected = _float + cashTaken;
|
||
final variance = _countedTotal - expected;
|
||
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: AppBar(
|
||
title: const Text('End shift'),
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back_rounded),
|
||
onPressed: _pushing ? null : () => context.pop(),
|
||
tooltip: 'Back to the till',
|
||
),
|
||
),
|
||
body: LayoutBuilder(
|
||
builder: (context, box) {
|
||
final twoColumn = box.maxWidth >= EndShiftScreen.twoColumnAbove;
|
||
final pad = box.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
|
||
|
||
final count = _CountPanel(
|
||
counted: _counted,
|
||
openingFloat: _openingFloat,
|
||
enabled: !_pushing,
|
||
total: _countedTotal,
|
||
noteCount: _noteCount,
|
||
onChanged: _set,
|
||
onFloatChanged: () => setState(() {}),
|
||
);
|
||
|
||
final review = _ReviewPanel(
|
||
report: report,
|
||
user: user?.name,
|
||
openingFloat: _float,
|
||
cashTaken: cashTaken,
|
||
expected: expected,
|
||
counted: _countedTotal,
|
||
variance: variance,
|
||
pending: pending,
|
||
error: _error,
|
||
);
|
||
|
||
final minHeight = box.maxHeight.isFinite
|
||
? (box.maxHeight - pad * 2).clamp(0.0, double.infinity)
|
||
: 0.0;
|
||
|
||
return SingleChildScrollView(
|
||
padding: EdgeInsets.all(pad),
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(minHeight: minHeight),
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 1240),
|
||
child: twoColumn
|
||
? Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: count),
|
||
const SizedBox(width: AppSpacing.lg),
|
||
Expanded(child: review),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
count,
|
||
const SizedBox(height: AppSpacing.lg),
|
||
review,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
bottomNavigationBar: _bottomBar(pending, variance),
|
||
);
|
||
}
|
||
|
||
Widget _bottomBar(int pending, double variance) {
|
||
final counted = _noteCount > 0;
|
||
final short = variance < -0.5;
|
||
final over = variance > 0.5;
|
||
|
||
// The gate. A shift closes on a drawer that reconciles and on nothing
|
||
// else — an uncounted or mismatched till is settled at the counter, with
|
||
// the person who worked it still there, not discovered by the back office
|
||
// the next morning.
|
||
final balanced = counted && !short && !over;
|
||
|
||
return SafeArea(
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpacing.xxl,
|
||
AppSpacing.md,
|
||
AppSpacing.xxl,
|
||
AppSpacing.lg,
|
||
),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.surface,
|
||
border: Border(top: BorderSide(color: AppColors.border)),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
counted
|
||
? (short || over
|
||
? Icons.error_outline_rounded
|
||
: Icons.check_circle_outline_rounded)
|
||
: Icons.info_outline_rounded,
|
||
size: 16,
|
||
color: !counted
|
||
? AppColors.textTertiary
|
||
: (short
|
||
? AppColors.danger
|
||
: (over ? AppColors.warning : AppColors.success)),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
!counted
|
||
? 'Count the drawer. The shift cannot be ended until '
|
||
'it matches the expected amount.'
|
||
: short
|
||
? 'The drawer is '
|
||
'${Formatters.money(variance.abs())} short. '
|
||
'Recount, or settle the difference — the '
|
||
'shift cannot be ended while it is off.'
|
||
: over
|
||
? 'The drawer is '
|
||
'${Formatters.money(variance)} over. '
|
||
'Recount — the shift cannot be ended '
|
||
'while it is off.'
|
||
: 'The drawer matches what was rung. Ready to '
|
||
'end the shift.',
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
PrimaryButton(
|
||
label: !balanced
|
||
? 'Drawer must match to end shift'
|
||
: pending > 0
|
||
? 'Upload $pending bill(s) & end shift'
|
||
: 'End shift',
|
||
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
|
||
large: true,
|
||
busy: _pushing,
|
||
onPressed: (_pushing || !balanced)
|
||
? null
|
||
: () => _finish(sync: pending > 0),
|
||
),
|
||
if (pending > 0) ...[
|
||
const SizedBox(height: AppSpacing.xs),
|
||
TextButton(
|
||
// Skipping the upload is still allowed; skipping the count is
|
||
// not, so this is gated on the same condition.
|
||
onPressed:
|
||
(_pushing || !balanced) ? null : () => _finish(sync: false),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: AppColors.textSecondary,
|
||
),
|
||
child: const Text('End shift without uploading'),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------- Count
|
||
class _CountPanel extends StatelessWidget {
|
||
const _CountPanel({
|
||
required this.counted,
|
||
required this.openingFloat,
|
||
required this.enabled,
|
||
required this.total,
|
||
required this.noteCount,
|
||
required this.onChanged,
|
||
required this.onFloatChanged,
|
||
});
|
||
|
||
final Map<int, int> counted;
|
||
final TextEditingController openingFloat;
|
||
final bool enabled;
|
||
final double total;
|
||
final int noteCount;
|
||
final void Function(int denomination, int count) onChanged;
|
||
final VoidCallback onFloatChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _Panel(
|
||
title: 'Count the drawer',
|
||
subtitle: 'Tap the notes and coins you are holding. Nothing is '
|
||
'submitted until you end the shift.',
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextField(
|
||
controller: openingFloat,
|
||
enabled: enabled,
|
||
keyboardType: TextInputType.number,
|
||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||
onChanged: (_) => onFloatChanged(),
|
||
decoration: const InputDecoration(
|
||
labelText: 'Opening float',
|
||
helperText: 'What was in the drawer before trading started',
|
||
prefixText: '₹ ',
|
||
isDense: true,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
const Divider(height: 1),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
|
||
for (final value in _denominations)
|
||
_DenominationRow(
|
||
value: value,
|
||
count: counted[value] ?? 0,
|
||
enabled: enabled,
|
||
onChanged: (n) => onChanged(value, n),
|
||
),
|
||
|
||
const SizedBox(height: AppSpacing.sm),
|
||
const Divider(height: 1),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
noteCount == 0
|
||
? 'Counted so far'
|
||
: 'Counted so far · $noteCount piece(s)',
|
||
style: const TextStyle(
|
||
fontSize: 13.5,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
Formatters.money(total),
|
||
style: AppTypography.money(20, color: AppColors.textPrimary),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DenominationRow extends StatelessWidget {
|
||
const _DenominationRow({
|
||
required this.value,
|
||
required this.count,
|
||
required this.enabled,
|
||
required this.onChanged,
|
||
});
|
||
|
||
final int value;
|
||
final int count;
|
||
final bool enabled;
|
||
final ValueChanged<int> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final subtotal = value * count;
|
||
final active = count > 0;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 74,
|
||
child: Text(
|
||
'₹$value',
|
||
style: AppTypography.money(
|
||
15,
|
||
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||
),
|
||
),
|
||
),
|
||
const Text(
|
||
'×',
|
||
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
_Stepper(
|
||
count: count,
|
||
enabled: enabled,
|
||
onChanged: onChanged,
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
active ? Formatters.money(subtotal.toDouble()) : '—',
|
||
style: AppTypography.money(
|
||
14,
|
||
color: active ? AppColors.textPrimary : AppColors.textTertiary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Stepper extends StatelessWidget {
|
||
const _Stepper({
|
||
required this.count,
|
||
required this.enabled,
|
||
required this.onChanged,
|
||
});
|
||
|
||
final int count;
|
||
final bool enabled;
|
||
final ValueChanged<int> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: AppRadius.brSm,
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_btn(
|
||
Icons.remove_rounded,
|
||
enabled && count > 0 ? () => onChanged(count - 1) : null,
|
||
),
|
||
Container(
|
||
constraints: const BoxConstraints(minWidth: 38),
|
||
alignment: Alignment.center,
|
||
child: Text('$count', style: AppTypography.money(14.5)),
|
||
),
|
||
_btn(
|
||
Icons.add_rounded,
|
||
enabled ? () => onChanged(count + 1) : null,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: AppRadius.brSm,
|
||
child: SizedBox(
|
||
width: 32,
|
||
height: 32,
|
||
child: Icon(
|
||
icon,
|
||
size: 16,
|
||
color: onTap == null
|
||
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||
: AppColors.primary,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Review
|
||
class _ReviewPanel extends StatelessWidget {
|
||
const _ReviewPanel({
|
||
required this.report,
|
||
required this.user,
|
||
required this.openingFloat,
|
||
required this.cashTaken,
|
||
required this.expected,
|
||
required this.counted,
|
||
required this.variance,
|
||
required this.pending,
|
||
required this.error,
|
||
});
|
||
|
||
final ShiftReport? report;
|
||
final String? user;
|
||
final double openingFloat;
|
||
final double cashTaken;
|
||
final double expected;
|
||
final double counted;
|
||
final double variance;
|
||
final int pending;
|
||
final String? error;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final short = variance < -0.5;
|
||
final over = variance > 0.5;
|
||
final tone = counted == 0
|
||
? AppColors.textTertiary
|
||
: (short ? AppColors.danger : (over ? AppColors.warning
|
||
: AppColors.success));
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_Panel(
|
||
title: 'Cash drawer',
|
||
subtitle: 'What the terminal expects, against what you counted.',
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_row('Opening float', Formatters.money(openingFloat)),
|
||
_row('Cash sales today', Formatters.money(cashTaken)),
|
||
const Divider(height: AppSpacing.xl),
|
||
_row(
|
||
'Expected in drawer',
|
||
Formatters.money(expected),
|
||
strong: true,
|
||
),
|
||
_row('You counted', Formatters.money(counted)),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Container(
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
decoration: BoxDecoration(
|
||
color: counted == 0
|
||
? AppColors.surfaceAlt
|
||
: (short
|
||
? AppColors.dangerSurface
|
||
: (over
|
||
? AppColors.warningSurface
|
||
: AppColors.successSurface)),
|
||
borderRadius: AppRadius.brMd,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
counted == 0
|
||
? 'Not counted yet'
|
||
: (short
|
||
? 'Short'
|
||
: (over ? 'Over' : 'Balanced')),
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: tone,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
counted == 0
|
||
? '—'
|
||
: '${variance >= 0 ? '+' : '−'}'
|
||
'${Formatters.money(variance.abs())}',
|
||
style: AppTypography.money(20, color: tone),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
|
||
_Panel(
|
||
title: 'Today at this till',
|
||
subtitle: user == null ? null : 'Rung by $user',
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_row('Bills', '${report?.billCount ?? 0}'),
|
||
_row('Items sold',
|
||
(report?.itemCount ?? 0).toStringAsFixed(0),),
|
||
_row('Gross sales',
|
||
Formatters.money(report?.grossSales ?? 0),),
|
||
_row('GST collected',
|
||
Formatters.money(report?.taxCollected ?? 0),),
|
||
if ((report?.paymentBreakdown ?? const {}).isNotEmpty) ...[
|
||
const Divider(height: AppSpacing.xl),
|
||
for (final e in report!.paymentBreakdown.entries)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(
|
||
children: [
|
||
Icon(methodIcon(e.key),
|
||
size: 16, color: AppColors.textSecondary,),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
e.key.label,
|
||
style: const TextStyle(
|
||
fontSize: 13.5,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
Text(Formatters.money(e.value),
|
||
style: AppTypography.money(13.5),),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
|
||
_Panel(
|
||
title: 'Before you hand it over',
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_checkRow(
|
||
pending == 0,
|
||
pending == 0
|
||
? 'Every bill has been uploaded.'
|
||
: '$pending bill(s) still on this terminal — they upload '
|
||
'when you end the shift.',
|
||
),
|
||
_checkRow(
|
||
!short && !over && counted > 0,
|
||
counted == 0
|
||
? 'Drawer not counted yet — required before the shift can '
|
||
'be ended.'
|
||
: (short || over)
|
||
? 'Drawer does not match the expected amount. The '
|
||
'shift stays open until it does.'
|
||
: 'Drawer matches the expected amount.',
|
||
),
|
||
_checkRow(
|
||
false,
|
||
'Products are removed from this terminal at the end of a '
|
||
'shift. An admin imports them again tomorrow.',
|
||
neutral: true,
|
||
),
|
||
if (error != null) ...[
|
||
const SizedBox(height: AppSpacing.md),
|
||
Container(
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.dangerSurface,
|
||
borderRadius: AppRadius.brSm,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Icon(Icons.wifi_off_rounded,
|
||
size: 18, color: AppColors.danger,),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
error!,
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.danger,
|
||
height: 1.45,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _row(String label, String value, {bool strong = false}) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: strong ? 14 : 13.5,
|
||
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
|
||
color: strong
|
||
? AppColors.textPrimary
|
||
: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: AppTypography.money(
|
||
strong ? 16 : 13.5,
|
||
color: strong ? AppColors.primary : null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
Widget _checkRow(bool done, String text, {bool neutral = false}) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
neutral
|
||
? Icons.info_outline_rounded
|
||
: (done
|
||
? Icons.check_circle_outline_rounded
|
||
: Icons.radio_button_unchecked_rounded),
|
||
size: 16,
|
||
color: neutral
|
||
? AppColors.textTertiary
|
||
: (done ? AppColors.success : AppColors.textTertiary),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.textSecondary,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// One card shape for every block on this screen, so the two columns line up
|
||
/// row for row instead of each panel inventing its own padding.
|
||
class _Panel extends StatelessWidget {
|
||
const _Panel({
|
||
required this.title,
|
||
required this.child,
|
||
this.subtitle,
|
||
});
|
||
|
||
final String title;
|
||
final String? subtitle;
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: AppRadius.brXl,
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: -0.2,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
if (subtitle != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
subtitle!,
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
color: AppColors.textSecondary,
|
||
height: 1.45,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: AppSpacing.lg),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|