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 '../../../domain/entities/promo.dart'; import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; import '../../../core/widgets/empty_state.dart'; import '../widgets/module_widgets.dart'; import '../widgets/promo_editor_dialog.dart'; /// Discount rules and campaigns. /// /// This was a mockup: three hardcoded rows with a toggle that changed nothing, /// and no promo code anywhere in the domain or data layers. A cashier looking /// at it would reasonably conclude promotions were running. They were not. class PromosView extends ConsumerWidget { const PromosView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final promosAsync = ref.watch(promosProvider); final isAdmin = ref.watch(currentUserProvider)?.role == StaffRole.admin; return ModulePage( children: [ promosAsync.when( loading: () => const Padding( padding: EdgeInsets.all(AppSpacing.xxl), child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Text('Could not load campaigns: $e'), data: (promos) => Column( children: [ _summary(promos), const SizedBox(height: AppSpacing.lg), _campaignList(context, ref, promos, isAdmin: isAdmin), ], ), ), ], ); } Widget _summary(List promos) { final live = promos.where((p) => p.isLiveAt(DateTime.now())).length; final scheduled = promos .where((p) => p.isActive && p.validFrom != null && p.validFrom!.isAfter(DateTime.now()),) .length; return Row( children: [ Expanded( child: StatTile( label: 'Running now', value: '$live', icon: Icons.play_circle_outline_rounded, color: AppColors.success, ), ), const SizedBox(width: AppSpacing.md), Expanded( child: StatTile( label: 'Scheduled', value: '$scheduled', icon: Icons.schedule_rounded, color: AppColors.info, ), ), const SizedBox(width: AppSpacing.md), Expanded( child: StatTile( label: 'Paused', value: '${promos.where((p) => !p.isActive).length}', icon: Icons.pause_circle_outline_rounded, color: AppColors.textTertiary, ), ), ], ); } Widget _campaignList( BuildContext context, WidgetRef ref, List promos, { required bool isAdmin, }) { return PanelCard( title: 'Campaigns', subtitle: promos.isEmpty ? 'Nothing running. Add a campaign and the till applies it ' 'automatically.' : 'Applied automatically at the till, in priority order', action: isAdmin ? FilledButton.icon( onPressed: () => showPromoEditor(context), icon: const Icon(Icons.add_rounded, size: 18), label: const Text('New campaign'), style: FilledButton.styleFrom( backgroundColor: AppColors.primary, minimumSize: const Size(0, 40), ), ) : null, child: promos.isEmpty ? const EmptyState( title: 'No campaigns yet', message: 'A campaign here is applied to every bill that ' 'qualifies, without the cashier doing anything.', emoji: '🏷️', compact: true, ) : Column( mainAxisSize: MainAxisSize.min, children: [ for (final promo in promos) _PromoRow(promo: promo, isAdmin: isAdmin), ], ), ); } } class _PromoRow extends ConsumerWidget { const _PromoRow({required this.promo, required this.isAdmin}); final Promo promo; final bool isAdmin; @override Widget build(BuildContext context, WidgetRef ref) { final live = promo.isLiveAt(DateTime.now()); return 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: 360, child: Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: (live ? AppColors.success : AppColors.textTertiary) .withValues(alpha: 0.12), borderRadius: AppRadius.brSm, ), child: Icon( Icons.sell_rounded, size: 18, color: live ? AppColors.success : AppColors.textTertiary, ), ), const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( promo.name, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700), ), Text( promo.summary, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12.5, color: AppColors.textSecondary, ), ), ], ), ), ], ), ), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: AppSpacing.sm, children: [ if (promo.stackable) const TagChip('Stacks', color: AppColors.info), // The distinction a shop actually needs: switched on, but out of // its date range or wrong day, is not the same as switched off. if (promo.isActive && !live) const TagChip('Not today', color: AppColors.warning), Text( _window(promo), style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), if (isAdmin) ...[ Switch( value: promo.isActive, onChanged: (v) async { await ref .read(localStoreProvider) .promos .setActive(promo.id, active: v); ref ..invalidate(promosProvider) ..invalidate(activePromosProvider); }, ), IconButton( tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 17), onPressed: () => showPromoEditor(context, existing: promo), ), IconButton( tooltip: 'Delete', icon: const Icon(Icons.delete_outline_rounded, size: 17), color: AppColors.danger, onPressed: () => _confirmDelete(context, ref), ), ], ], ), ], ), ); } String _window(Promo promo) { final from = promo.validFrom; final to = promo.validTo; if (from == null && to == null) { return promo.daysOfWeek.isEmpty ? 'Always' : _days(promo.daysOfWeek); } final range = [ if (from != null) 'from ${Formatters.date(from)}', if (to != null) 'to ${Formatters.date(to)}', ].join(' '); return promo.daysOfWeek.isEmpty ? range : '$range · ${_days(promo.daysOfWeek)}'; } static String _days(Set days) { const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; final sorted = days.toList()..sort(); return sorted.map((d) => names[d - 1]).join(', '); } Future _confirmDelete(BuildContext context, WidgetRef ref) async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text('Delete "${promo.name}"?'), content: const Text( 'Bills already rung keep the discount they were given — a bill ' 'stores the amount, not a link to the campaign. Only future sales ' 'are affected.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), style: TextButton.styleFrom(foregroundColor: AppColors.danger), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; await ref.read(localStoreProvider).promos.delete(promo.id); ref ..invalidate(promosProvider) ..invalidate(activePromosProvider); } }