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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user