Files
nearle_pos/lib/presentation/modules/screens/customers_view.dart
Suriya 174fdddb8a Wire the dead Add customer button to the existing capture sheet
The Customers module's primary action did nothing. The sheet it needed was
already built and in use at checkout, so this reuses it — a customer added
from the book now goes through the same validation, including the
duplicate-mobile guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:43:05 +05:30

217 lines
8.1 KiB
Dart

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 '../../customer/widgets/customer_capture_sheet.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(
// Reuses the sheet the till already opens at checkout, so a
// customer added here is validated the same way — including the
// duplicate-mobile guard.
onPressed: () async {
await showCustomerCaptureSheet(context);
ref.invalidate(allCustomersProvider);
},
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(),
),
],
),
),
],
);
}
}