second commit
This commit is contained in:
209
lib/presentation/modules/screens/customers_view.dart
Normal file
209
lib/presentation/modules/screens/customers_view.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/status_pill.dart';
|
||||
import '../../../domain/entities/customer.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// Full customer book, independent of the six shown during billing.
|
||||
final allCustomersProvider = FutureProvider<List<Customer>>(
|
||||
(ref) => ref.watch(customerRepositoryProvider).recent(limit: 200),
|
||||
);
|
||||
|
||||
class CustomersView extends ConsumerStatefulWidget {
|
||||
const CustomersView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CustomersView> createState() => _CustomersViewState();
|
||||
}
|
||||
|
||||
class _CustomersViewState extends ConsumerState<CustomersView> {
|
||||
String _query = '';
|
||||
MembershipTier? _tier;
|
||||
|
||||
Color _tierColor(MembershipTier t) => switch (t) {
|
||||
MembershipTier.bronze => AppColors.tierBronze,
|
||||
MembershipTier.silver => AppColors.tierSilver,
|
||||
MembershipTier.gold => AppColors.tierGold,
|
||||
MembershipTier.platinum => AppColors.tierPlatinum,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final all = ref.watch(allCustomersProvider).value ?? const <Customer>[];
|
||||
|
||||
final filtered = all.where((c) {
|
||||
final q = _query.trim().toLowerCase();
|
||||
final matchesQuery = q.isEmpty ||
|
||||
c.name.toLowerCase().contains(q) ||
|
||||
c.mobile.contains(q);
|
||||
return matchesQuery && (_tier == null || c.tier == _tier);
|
||||
}).toList();
|
||||
|
||||
final lifetime = all.fold<double>(0, (s, c) => s + c.lifetimeSpend);
|
||||
final points = all.fold<int>(0, (s, c) => s + c.loyaltyPoints);
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Total Customers',
|
||||
value: '${all.length}',
|
||||
icon: Icons.people_alt_rounded,
|
||||
caption: 'registered',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Lifetime Value',
|
||||
value: Formatters.moneyCompact(lifetime),
|
||||
icon: Icons.payments_rounded,
|
||||
color: AppColors.success,
|
||||
caption: 'all customers',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Points Outstanding',
|
||||
value: '$points',
|
||||
icon: Icons.stars_rounded,
|
||||
color: AppColors.tierGold,
|
||||
caption: 'worth ${Formatters.money(points * 0.25)}',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Avg Spend',
|
||||
value: Formatters.money(all.isEmpty ? 0 : lifetime / all.length),
|
||||
icon: Icons.trending_up_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'per customer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Tier distribution',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final t in MembershipTier.values)
|
||||
ProgressRow(
|
||||
label: '${t.label} · '
|
||||
'${(t.discountRate * 100).toStringAsFixed(0)}% off',
|
||||
value: '${all.where((c) => c.tier == t).length}',
|
||||
fraction: all.isEmpty
|
||||
? 0
|
||||
: all.where((c) => c.tier == t).length / all.length,
|
||||
color: _tierColor(t),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Customer book',
|
||||
subtitle: '${filtered.length} shown',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
||||
label: const Text('Add customer'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search by name or mobile number…',
|
||||
prefixIcon: Icon(Icons.search_rounded),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('All tiers'),
|
||||
selected: _tier == null,
|
||||
onSelected: (_) => setState(() => _tier = null),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _tier == null
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
for (final t in MembershipTier.values)
|
||||
ChoiceChip(
|
||||
label: Text(t.label),
|
||||
selected: _tier == t,
|
||||
onSelected: (_) =>
|
||||
setState(() => _tier = _tier == t ? null : t),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _tier == t
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Customer', flex: 4),
|
||||
TableCol('Mobile', flex: 3, priority: 1),
|
||||
TableCol('Tier', flex: 2),
|
||||
TableCol('Points', flex: 2, numeric: true, priority: 1),
|
||||
TableCol('Lifetime', flex: 2, numeric: true),
|
||||
TableCol('Visits', flex: 2, numeric: true, priority: 1),
|
||||
],
|
||||
rows: filtered
|
||||
.map((c) => [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: AppColors.primarySurface,
|
||||
child: Text(
|
||||
Formatters.initials(c.name),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(c.name, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(Formatters.mobile(c.mobile), mono: true),
|
||||
StatusPill.tier(c.tier, dense: true),
|
||||
Cell('${c.loyaltyPoints}', mono: true),
|
||||
Cell(Formatters.moneyCompact(c.lifetimeSpend),
|
||||
mono: true, bold: true),
|
||||
Cell('${c.visitCount}', mono: true),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
287
lib/presentation/modules/screens/events_view.dart
Normal file
287
lib/presentation/modules/screens/events_view.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// The terminal's outbound half: what today produced, and what has been sent.
|
||||
class EventsView extends ConsumerWidget {
|
||||
const EventsView({super.key});
|
||||
|
||||
static Color statusColor(SyncStatus s) => switch (s) {
|
||||
SyncStatus.synced => AppColors.success,
|
||||
SyncStatus.failed => AppColors.danger,
|
||||
SyncStatus.syncing => AppColors.info,
|
||||
SyncStatus.pending => AppColors.warning,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final report = ref.watch(shiftReportProvider);
|
||||
final events = ref.watch(syncEventsProvider);
|
||||
final pushing = ref.watch(reportPushProvider);
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Bills Today',
|
||||
value: '${report.billCount}',
|
||||
icon: Icons.receipt_long_rounded,
|
||||
caption: report.firstBillAt == null
|
||||
? 'no sales yet'
|
||||
: '${Formatters.time(report.firstBillAt!)} – '
|
||||
'${Formatters.time(report.lastBillAt!)}',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Items Sold',
|
||||
value: report.itemCount.toStringAsFixed(0),
|
||||
icon: Icons.shopping_basket_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'units across all bills',
|
||||
),
|
||||
StatTile(
|
||||
label: "Today's Sales",
|
||||
value: Formatters.money(report.grossSales),
|
||||
icon: Icons.payments_rounded,
|
||||
color: AppColors.success,
|
||||
caption: 'gross takings',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Average Basket',
|
||||
value: Formatters.money(report.averageBasket),
|
||||
icon: Icons.trending_up_rounded,
|
||||
color: AppColors.tierGold,
|
||||
caption: 'per bill',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Shift report',
|
||||
subtitle: '${Formatters.date(report.businessDate)} · '
|
||||
'${report.cashierName} · ${report.terminalId}',
|
||||
action: TagChip(
|
||||
report.isEmpty ? 'Nothing to send' : 'Ready to push',
|
||||
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Bills', '${report.billCount}'),
|
||||
_row('Items sold', report.itemCount.toStringAsFixed(0)),
|
||||
_row('Gross sales', Formatters.money(report.grossSales)),
|
||||
_row('Net of tax', Formatters.money(report.netOfTax)),
|
||||
_row('GST collected', Formatters.money(report.taxCollected)),
|
||||
_row('Discount given', Formatters.money(report.discountGiven)),
|
||||
_row('Round off', Formatters.money(report.roundOff)),
|
||||
_row('Points issued', '${report.loyaltyPointsIssued}'),
|
||||
_row('Points redeemed', '${report.loyaltyPointsRedeemed}'),
|
||||
|
||||
if (report.paymentBreakdown.isNotEmpty) ...[
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'By payment method',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
for (final e in report.paymentBreakdown.entries)
|
||||
ProgressRow(
|
||||
label: '${e.key.emoji} ${e.key.label}',
|
||||
value: Formatters.money(e.value),
|
||||
fraction: report.grossSales <= 0
|
||||
? 0
|
||||
: e.value / report.grossSales,
|
||||
color: _methodColor(e.key),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
PrimaryButton(
|
||||
label: 'Push report to server',
|
||||
icon: Icons.cloud_upload_rounded,
|
||||
large: true,
|
||||
busy: pushing,
|
||||
onPressed: report.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
final event = await ref
|
||||
.read(reportPushProvider.notifier)
|
||||
.pushToday();
|
||||
if (!context.mounted) return;
|
||||
final ok = event.status == SyncStatus.synced;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
backgroundColor:
|
||||
ok ? AppColors.success : AppColors.danger,
|
||||
content: Text(
|
||||
ok
|
||||
? 'Shift report sent.'
|
||||
: 'Push failed — the report is still saved '
|
||||
'on this terminal.',
|
||||
),
|
||||
));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined,
|
||||
size: 15, color: AppColors.textTertiary),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'A failed push never discards data. The report stays '
|
||||
'queued below and can be retried at any time.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Event log',
|
||||
subtitle: '${events.length} recorded · '
|
||||
'${events.where((e) => e.status != SyncStatus.synced).length} '
|
||||
'outstanding',
|
||||
child: events.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
|
||||
child: Text(
|
||||
'No sync activity yet. Importing the catalogue or pushing '
|
||||
'a report will appear here.',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
)
|
||||
: ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Event', flex: 3),
|
||||
TableCol('Detail', flex: 5, priority: 1),
|
||||
TableCol('Time', flex: 2, numeric: true, priority: 1),
|
||||
TableCol('Status', flex: 2, numeric: true),
|
||||
],
|
||||
rows: events
|
||||
.map((e) => [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
e.type.isInbound
|
||||
? Icons.cloud_download_rounded
|
||||
: Icons.cloud_upload_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(e.type.label, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(
|
||||
e.error ?? e.summary,
|
||||
color: e.error != null
|
||||
? AppColors.danger
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
Cell(Formatters.time(e.createdAt),
|
||||
color: AppColors.textTertiary),
|
||||
e.status == SyncStatus.failed
|
||||
? _RetryButton(eventId: e.id)
|
||||
: TagChip(
|
||||
e.status.label,
|
||||
color: statusColor(e.status),
|
||||
),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Color _methodColor(PaymentMethod m) => switch (m) {
|
||||
PaymentMethod.cash => AppColors.success,
|
||||
PaymentMethod.card => AppColors.info,
|
||||
PaymentMethod.upi => AppColors.primary,
|
||||
PaymentMethod.wallet => AppColors.warning,
|
||||
PaymentMethod.giftCard => AppColors.tierGold,
|
||||
PaymentMethod.loyalty => AppColors.tierSilver,
|
||||
};
|
||||
|
||||
Widget _row(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RetryButton extends ConsumerWidget {
|
||||
const _RetryButton({required this.eventId});
|
||||
|
||||
final String eventId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(reportPushProvider);
|
||||
|
||||
return TextButton.icon(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () => ref.read(reportPushProvider.notifier).retry(eventId),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 15),
|
||||
label: const Text('Retry', style: TextStyle(fontSize: 12.5)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.danger,
|
||||
minimumSize: const Size(0, 30),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
338
lib/presentation/modules/screens/product_import_view.dart
Normal file
338
lib/presentation/modules/screens/product_import_view.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../domain/entities/product.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../pos/providers/navigation_provider.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// Pulls the catalogue onto the terminal.
|
||||
///
|
||||
/// This is the first thing a cashier does at the start of a session — until it
|
||||
/// succeeds there is nothing to bill. Once imported, everything runs locally.
|
||||
class ProductImportView extends ConsumerWidget {
|
||||
const ProductImportView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(catalogueImportProvider);
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final lastImport = ref.watch(lastImportAtProvider);
|
||||
final products = ref.watch(allProductsProvider).value ?? const <Product>[];
|
||||
final revision = ref.watch(syncRepositoryProvider).catalogueRevision;
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
if (!ready) _NotImportedBanner(state: state),
|
||||
|
||||
if (ready) ...[
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Products Loaded',
|
||||
value: '${products.length}',
|
||||
icon: Icons.inventory_2_rounded,
|
||||
color: AppColors.success,
|
||||
caption: 'available offline',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Catalogue Revision',
|
||||
value: revision ?? '—',
|
||||
icon: Icons.tag_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'server version',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Last Imported',
|
||||
value: lastImport == null
|
||||
? '—'
|
||||
: Formatters.time(lastImport),
|
||||
icon: Icons.schedule_rounded,
|
||||
caption: lastImport == null
|
||||
? 'never'
|
||||
: Formatters.date(lastImport),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Stock Value',
|
||||
value: Formatters.moneyCompact(
|
||||
products.fold<double>(0, (s, p) => s + p.price * p.stock),
|
||||
),
|
||||
icon: Icons.savings_rounded,
|
||||
color: AppColors.tierGold,
|
||||
caption: 'at selling price',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
PanelCard(
|
||||
title: ready ? 'Re-import catalogue' : 'Import catalogue',
|
||||
subtitle: ready
|
||||
? 'Pulls the latest prices and products. Stock already sold on '
|
||||
'this terminal is preserved.'
|
||||
: 'Connect once to load products, then bill offline all day.',
|
||||
child: _ImportPanel(state: state, ready: ready),
|
||||
),
|
||||
|
||||
if (ready) ...[
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
PanelCard(
|
||||
title: 'Imported products',
|
||||
subtitle: '${products.length} items on this terminal',
|
||||
child: ResponsiveTable(
|
||||
columns: const [
|
||||
TableCol('Product', flex: 4),
|
||||
TableCol('SKU', flex: 3, priority: 1),
|
||||
TableCol('Category', flex: 2, priority: 1),
|
||||
TableCol('Price', flex: 2, numeric: true),
|
||||
TableCol('Stock', flex: 2, numeric: true),
|
||||
],
|
||||
rows: products
|
||||
.map((p) => [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(p.emoji,
|
||||
style: const TextStyle(fontSize: 17)),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Flexible(child: Cell(p.name, bold: true)),
|
||||
],
|
||||
),
|
||||
Cell(p.sku, color: AppColors.textTertiary),
|
||||
TagChip(p.category.label,
|
||||
color: AppColors.textSecondary),
|
||||
Cell(Formatters.money(p.price), mono: true, bold: true),
|
||||
TagChip(
|
||||
p.isOutOfStock
|
||||
? 'Out'
|
||||
: '${p.stock.toStringAsFixed(0)} ${p.unit.symbol}',
|
||||
color: p.isOutOfStock
|
||||
? AppColors.danger
|
||||
: (p.isLowStock
|
||||
? AppColors.warning
|
||||
: AppColors.success),
|
||||
),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotImportedBanner extends StatelessWidget {
|
||||
const _NotImportedBanner({required this.state});
|
||||
|
||||
final ImportState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningSurface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.cloud_download_outlined,
|
||||
color: AppColors.warning, size: 22),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No catalogue on this terminal',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Billing is disabled until products are imported. This is '
|
||||
'the only step that needs a connection at the start of a '
|
||||
'shift.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportPanel extends ConsumerWidget {
|
||||
const _ImportPanel({required this.state, required this.ready});
|
||||
|
||||
final ImportState state;
|
||||
final bool ready;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final running = state is ImportRunning;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state is ImportRunning) ...[
|
||||
Text(
|
||||
(state as ImportRunning).stage,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.brPill,
|
||||
child: LinearProgressIndicator(
|
||||
value: (state as ImportRunning).progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.divider,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
|
||||
if (state is ImportFailed) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.dangerSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.danger, size: 18),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
(state as ImportFailed).message,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (state is ImportDone) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline_rounded,
|
||||
color: AppColors.success, size: 18),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
(state as ImportDone).event.summary,
|
||||
style: const TextStyle(
|
||||
color: AppColors.success,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Wrap so the buttons stack rather than overflow on a narrow panel.
|
||||
Wrap(
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.md,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: PrimaryButton(
|
||||
label: ready ? 'Re-import now' : 'Import catalogue',
|
||||
icon: Icons.cloud_download_rounded,
|
||||
large: true,
|
||||
busy: running,
|
||||
onPressed: running
|
||||
? null
|
||||
: () => ref.read(catalogueImportProvider.notifier).run(),
|
||||
),
|
||||
),
|
||||
if (ready && !running)
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: PrimaryButton(
|
||||
label: 'Start billing',
|
||||
icon: Icons.point_of_sale_rounded,
|
||||
large: true,
|
||||
tone: ButtonTone.ghost,
|
||||
onPressed: () => ref
|
||||
.read(activeModuleProvider.notifier)
|
||||
.state = PosModule.pos,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 15, color: AppColors.textTertiary),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'After this import the terminal works entirely offline. '
|
||||
'Sales, customers and parked bills are held locally and are '
|
||||
'only sent when you push the shift report at sign-out.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
202
lib/presentation/modules/screens/promos_view.dart
Normal file
202
lib/presentation/modules/screens/promos_view.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// Discount rules and campaigns.
|
||||
class PromosView extends StatefulWidget {
|
||||
const PromosView({super.key});
|
||||
|
||||
@override
|
||||
State<PromosView> createState() => _PromosViewState();
|
||||
}
|
||||
|
||||
class _PromosViewState extends State<PromosView> {
|
||||
final Set<String> _enabled = {'WEEKEND10', 'DAIRY5', 'FESTIVE'};
|
||||
|
||||
static const _campaigns = [
|
||||
(
|
||||
'WEEKEND10',
|
||||
'Weekend Saver',
|
||||
'10% off bills above ₹500',
|
||||
'Sat–Sun',
|
||||
412,
|
||||
AppColors.primary,
|
||||
),
|
||||
(
|
||||
'DAIRY5',
|
||||
'Dairy Days',
|
||||
'5% off all dairy products',
|
||||
'Ends 31 Aug',
|
||||
286,
|
||||
AppColors.info,
|
||||
),
|
||||
(
|
||||
'FESTIVE',
|
||||
'Festive Bonus',
|
||||
'Double loyalty points',
|
||||
'Ends 15 Sep',
|
||||
178,
|
||||
AppColors.tierGold,
|
||||
),
|
||||
(
|
||||
'NEWCUST',
|
||||
'First Purchase',
|
||||
'₹50 off the first bill',
|
||||
'Always on',
|
||||
94,
|
||||
AppColors.success,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ModulePage(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: AppSpacing.lg,
|
||||
runSpacing: AppSpacing.lg,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Active Campaigns',
|
||||
value: '${_enabled.length}',
|
||||
icon: Icons.campaign_rounded,
|
||||
caption: 'of ${_campaigns.length} configured',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Redemptions',
|
||||
value: '970',
|
||||
icon: Icons.confirmation_number_rounded,
|
||||
color: AppColors.info,
|
||||
caption: 'this month',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Discount Given',
|
||||
value: '₹48,240',
|
||||
icon: Icons.local_offer_rounded,
|
||||
color: AppColors.warning,
|
||||
caption: '2.6% of sales',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Incremental Sales',
|
||||
value: '₹2.14L',
|
||||
icon: Icons.trending_up_rounded,
|
||||
color: AppColors.success,
|
||||
delta: '+18%',
|
||||
caption: 'attributed',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
PanelCard(
|
||||
title: 'Campaigns',
|
||||
subtitle: 'Toggle a rule to apply it at the till immediately',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('New campaign'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final c in _campaigns)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brMd,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
// Wrap prevents collision when the panel is narrow.
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: c.$6.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(Icons.sell_rounded,
|
||||
size: 18, color: c.$6),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
c.$2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
c.$3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TagChip(c.$1, color: c.$6),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
TagChip(c.$4, color: AppColors.textSecondary),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'${c.$5} used',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Switch(
|
||||
value: _enabled.contains(c.$1),
|
||||
onChanged: (v) => setState(() {
|
||||
if (v) {
|
||||
_enabled.add(c.$1);
|
||||
} else {
|
||||
_enabled.remove(c.$1);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
322
lib/presentation/modules/screens/settings_view.dart
Normal file
322
lib/presentation/modules/screens/settings_view.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../../domain/entities/sync_event.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../widgets/module_widgets.dart';
|
||||
|
||||
/// Terminal and store configuration.
|
||||
class SettingsView extends ConsumerStatefulWidget {
|
||||
const SettingsView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsView> createState() => _SettingsViewState();
|
||||
}
|
||||
|
||||
class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
bool _scannerSound = true;
|
||||
bool _autoPrint = true;
|
||||
bool _openDrawer = true;
|
||||
bool _roundOff = true;
|
||||
bool _autoLoyalty = true;
|
||||
bool _offline = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ref.watch(currentStoreProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return ModulePage(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 1040;
|
||||
|
||||
final left = Column(
|
||||
children: [
|
||||
_storeCard(store),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_taxCard(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_loyaltyCard(),
|
||||
],
|
||||
);
|
||||
final right = Column(
|
||||
children: [
|
||||
_hardwareCard(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_connectivityCard(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_staffCard(store, user),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_aboutCard(),
|
||||
],
|
||||
);
|
||||
|
||||
if (!wide) {
|
||||
return Column(
|
||||
children: [left, const SizedBox(height: AppSpacing.lg), right],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(child: right),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _storeCard(StoreAccount? store) => PanelCard(
|
||||
title: 'Store details',
|
||||
subtitle: 'Printed on every invoice',
|
||||
action: TextButton(onPressed: () {}, child: const Text('Edit')),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Store name', store?.name ?? AppConstants.storeName),
|
||||
_row('Address', store?.address ?? AppConstants.storeAddress),
|
||||
_row('GSTIN', store?.gstin ?? AppConstants.storeGstin, mono: true),
|
||||
_row('Phone', store?.phone ?? AppConstants.storePhone, mono: true),
|
||||
_row('Plan', store?.plan ?? 'Business'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _taxCard() => PanelCard(
|
||||
title: 'Tax & pricing',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row(
|
||||
'Default GST slab',
|
||||
Formatters.percent(AppConstants.defaultGstRate),
|
||||
),
|
||||
_row('Prices include tax', 'Yes'),
|
||||
_toggle(
|
||||
'Round bills to nearest rupee',
|
||||
'Shows the adjustment as a Round Off line',
|
||||
_roundOff,
|
||||
(v) => setState(() => _roundOff = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _loyaltyCard() => PanelCard(
|
||||
title: 'Loyalty programme',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row(
|
||||
'Earn rate',
|
||||
'1 point per '
|
||||
'${Formatters.money(AppConstants.loyaltyRupeesPerPoint)}',
|
||||
),
|
||||
_row(
|
||||
'Point value',
|
||||
Formatters.money(AppConstants.loyaltyPointValue),
|
||||
),
|
||||
_toggle(
|
||||
'Apply tier discount automatically',
|
||||
'Silver 2%, Gold 5%, Platinum 8%',
|
||||
_autoLoyalty,
|
||||
(v) => setState(() => _autoLoyalty = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _hardwareCard() => PanelCard(
|
||||
title: 'Hardware & peripherals',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_toggle(
|
||||
'Scanner beep',
|
||||
'Audible confirmation on every scan',
|
||||
_scannerSound,
|
||||
(v) => setState(() => _scannerSound = v),
|
||||
),
|
||||
_toggle(
|
||||
'Print receipt automatically',
|
||||
'Sends to the default roll printer with no dialog',
|
||||
_autoPrint,
|
||||
(v) => setState(() => _autoPrint = v),
|
||||
),
|
||||
_toggle(
|
||||
'Open cash drawer on cash sales',
|
||||
'Sends the ESC/POS kick pulse',
|
||||
_openDrawer,
|
||||
(v) => setState(() => _openDrawer = v),
|
||||
),
|
||||
const Divider(height: AppSpacing.xxl),
|
||||
_row('Receipt printer', 'EPSON TM-T82 (default)'),
|
||||
_row('Barcode scanner', 'Keyboard wedge · detected'),
|
||||
_row('Cash drawer', 'Connected via printer'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
|
||||
title: 'Users & roles',
|
||||
action: TextButton(onPressed: () {}, child: const Text('Manage')),
|
||||
child: ResponsiveTable(
|
||||
stackBelow: 360,
|
||||
columns: const [
|
||||
TableCol('Name', flex: 3),
|
||||
TableCol('Role', flex: 3),
|
||||
TableCol('', flex: 2, numeric: true),
|
||||
],
|
||||
rows: (store?.staff ?? const <StaffUser>[])
|
||||
.map((s) => [
|
||||
Cell(s.name, bold: true),
|
||||
Cell(s.role.label, color: AppColors.textSecondary),
|
||||
s.id == current?.id
|
||||
? const TagChip('Signed in',
|
||||
color: AppColors.success)
|
||||
: const SizedBox.shrink(),
|
||||
])
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _connectivityCard() {
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final lastImport = ref.watch(lastImportAtProvider);
|
||||
final outstanding = ref
|
||||
.watch(syncEventsProvider)
|
||||
.where((e) => e.status != SyncStatus.synced)
|
||||
.length;
|
||||
|
||||
return PanelCard(
|
||||
title: 'Connectivity & sync',
|
||||
subtitle: 'This terminal only needs a connection to import the '
|
||||
'catalogue and to push the shift report.',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Catalogue', ready ? 'Loaded' : 'Not imported'),
|
||||
_row(
|
||||
'Last import',
|
||||
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
||||
),
|
||||
_row('Outstanding pushes', '$outstanding'),
|
||||
_toggle(
|
||||
'Simulate offline',
|
||||
'Forces import and push to fail, so you can confirm nothing is '
|
||||
'lost when the network drops',
|
||||
_offline,
|
||||
(v) {
|
||||
setState(() => _offline = v);
|
||||
ref.read(remoteCatalogueProvider).simulateOffline = v;
|
||||
ref.read(remoteReportSinkProvider).simulateOffline = v;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _aboutCard() => PanelCard(
|
||||
title: 'About',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('Application', '${AppConstants.appName} 1.0.0'),
|
||||
_row('Terminal', 'TERM-01'),
|
||||
_row('Data store', 'Local — offline first'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.sync_rounded, size: 17),
|
||||
label: const Text('Check for updates'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(String label, String value, {bool mono = false}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 148,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
fontFamily: mono ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggle(
|
||||
String title,
|
||||
String subtitle,
|
||||
bool value,
|
||||
ValueChanged<bool> onChanged,
|
||||
) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
580
lib/presentation/modules/widgets/module_widgets.dart
Normal file
580
lib/presentation/modules/widgets/module_widgets.dart
Normal file
@@ -0,0 +1,580 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
|
||||
/// Scrollable page body shared by every module screen.
|
||||
///
|
||||
/// Always scrolls vertically, so no module can overflow no matter how short
|
||||
/// the viewport gets.
|
||||
class ModulePage extends StatelessWidget {
|
||||
const ModulePage({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.padding = AppSpacing.xxl,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final double padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// KPI card. Designed to sit inside a [Wrap] so it reflows instead of
|
||||
/// overflowing when the window narrows.
|
||||
class StatTile extends StatelessWidget {
|
||||
const StatTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color = AppColors.primary,
|
||||
this.delta,
|
||||
this.deltaPositive = true,
|
||||
this.caption,
|
||||
this.width = 232,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String? delta;
|
||||
final bool deltaPositive;
|
||||
final String? caption;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Icon(icon, size: 17, color: color),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(value, style: AppTypography.money(24)),
|
||||
),
|
||||
if (delta != null || caption != null) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
if (delta != null) ...[
|
||||
Icon(
|
||||
deltaPositive
|
||||
? Icons.trending_up_rounded
|
||||
: Icons.trending_down_rounded,
|
||||
size: 14,
|
||||
color:
|
||||
deltaPositive ? AppColors.success : AppColors.danger,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
delta!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
deltaPositive ? AppColors.success : AppColors.danger,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
],
|
||||
if (caption != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
caption!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Titled container for a block of module content.
|
||||
class PanelCard extends StatelessWidget {
|
||||
const PanelCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.subtitle,
|
||||
this.action,
|
||||
this.padding = AppSpacing.lg,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final String? subtitle;
|
||||
final Widget? action;
|
||||
final double padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(padding, padding, padding, AppSpacing.md),
|
||||
// Wrap so a long title plus an action never collide.
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppSpacing.md,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(padding: EdgeInsets.all(padding), child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One column of a [ResponsiveTable].
|
||||
class TableCol {
|
||||
const TableCol(
|
||||
this.label, {
|
||||
this.flex = 2,
|
||||
this.numeric = false,
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int flex;
|
||||
final bool numeric;
|
||||
|
||||
/// Higher numbers are dropped first as the table narrows.
|
||||
final int priority;
|
||||
}
|
||||
|
||||
/// Table that degrades into stacked cards rather than overflowing.
|
||||
///
|
||||
/// Above [stackBelow] it renders as aligned columns; below, each row becomes a
|
||||
/// label/value card. Low-priority columns are hidden at intermediate widths.
|
||||
class ResponsiveTable extends StatelessWidget {
|
||||
const ResponsiveTable({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
this.stackBelow = 620,
|
||||
this.hideSecondaryBelow = 900,
|
||||
this.onRowTap,
|
||||
});
|
||||
|
||||
final List<TableCol> columns;
|
||||
|
||||
/// Each row must supply exactly one cell per column.
|
||||
final List<List<Widget>> rows;
|
||||
|
||||
final double stackBelow;
|
||||
final double hideSecondaryBelow;
|
||||
final void Function(int index)? onRowTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (rows.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.xxl),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Nothing to show yet.',
|
||||
style: TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
|
||||
if (w < stackBelow) return _stacked();
|
||||
|
||||
final visible = <int>[
|
||||
for (var i = 0; i < columns.length; i++)
|
||||
if (w >= hideSecondaryBelow || columns[i].priority == 0) i,
|
||||
];
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in visible)
|
||||
Expanded(
|
||||
flex: columns[i].flex,
|
||||
child: Text(
|
||||
columns[i].label.toUpperCase(),
|
||||
textAlign:
|
||||
columns[i].numeric ? TextAlign.right : TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.sectionLabel(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
for (var r = 0; r < rows.length; r++)
|
||||
InkWell(
|
||||
onTap: onRowTap == null ? null : () => onRowTap!(r),
|
||||
borderRadius: AppRadius.brXs,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
border:
|
||||
Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in visible)
|
||||
Expanded(
|
||||
flex: columns[i].flex,
|
||||
child: Align(
|
||||
alignment: columns[i].numeric
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: rows[r][i],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stacked() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var r = 0; r < rows.length; r++)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brSm,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var c = 0; c < columns.length; c++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 104,
|
||||
child: Text(
|
||||
columns[c].label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: rows[r][c],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain text cell.
|
||||
class Cell extends StatelessWidget {
|
||||
const Cell(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.bold = false,
|
||||
this.color,
|
||||
this.mono = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool bold;
|
||||
final Color? color;
|
||||
final bool mono;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: mono
|
||||
? AppTypography.money(13.5,
|
||||
weight: bold ? FontWeight.w700 : FontWeight.w500, color: color)
|
||||
: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: bold ? FontWeight.w600 : FontWeight.w400,
|
||||
color: color ?? AppColors.textPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small coloured status label.
|
||||
class TagChip extends StatelessWidget {
|
||||
const TagChip(this.label, {super.key, this.color = AppColors.primary});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.brPill,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight bar chart painted in code, so no charting dependency is needed.
|
||||
class MiniBarChart extends StatelessWidget {
|
||||
const MiniBarChart({
|
||||
super.key,
|
||||
required this.values,
|
||||
required this.labels,
|
||||
this.height = 180,
|
||||
this.color = AppColors.primary,
|
||||
});
|
||||
|
||||
final List<double> values;
|
||||
final List<String> labels;
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (values.isEmpty) return SizedBox(height: height);
|
||||
final max = values.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Labels are dropped rather than squeezed when space is tight.
|
||||
final showLabels = constraints.maxWidth / values.length >= 28;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < values.length; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor:
|
||||
max <= 0 ? 0 : (values[i] / max).clamp(0.03, 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
color,
|
||||
color.withValues(alpha: 0.45),
|
||||
],
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLabels) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
labels[i],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal proportion bar used for breakdowns.
|
||||
class ProgressRow extends StatelessWidget {
|
||||
const ProgressRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.fraction,
|
||||
this.color = AppColors.primary,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final double fraction;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(value, style: AppTypography.money(13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.brPill,
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction.clamp(0, 1),
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.divider,
|
||||
valueColor: AlwaysStoppedAnimation(color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user