import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import '../../main.dart' show db; import '../database/daos/month_dao.dart'; import '../database/daos/settings_dao.dart'; import '../database/database.dart'; import '../services/rule_solver.dart'; import '../theme.dart'; import 'inline_edit_cell.dart'; /// Opens the settings modal as a large centered dialog. Future showSettingsModal({ required BuildContext context, required MonthData monthData, required VoidCallback onDataChanged, }) { return showDialog( context: context, builder: (_) => _SettingsDialog( monthData: monthData, onDataChanged: onDataChanged, ), ); } class _SettingsDialog extends StatefulWidget { final MonthData monthData; final VoidCallback onDataChanged; const _SettingsDialog({ required this.monthData, required this.onDataChanged, }); @override State<_SettingsDialog> createState() => _SettingsDialogState(); } class _SettingsDialogState extends State<_SettingsDialog> { late SettingsDao _settingsDao; late MonthDao _monthDao; late int _bufferCents; final _billModeOverrides = {}; // billId → paymentMode final _expandedBillIds = {}; // billIds with expanded step breakdown static const _fullMonthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; @override void initState() { super.initState(); _settingsDao = SettingsDao(db); _monthDao = MonthDao(db); _bufferCents = widget.monthData.plan.bufferAmountCents; } int get _monthPlanId => widget.monthData.plan.id; @override Widget build(BuildContext context) { final plan = widget.monthData.plan; final monthLabel = '${_fullMonthNames[plan.month - 1]} ${plan.year}'; return Dialog( backgroundColor: bgPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: const BorderSide(color: borderColor), ), insetPadding: const EdgeInsets.symmetric(horizontal: 60, vertical: 40), child: SizedBox( width: 760, child: Column( children: [ // Header Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( color: bgCard, border: Border(bottom: BorderSide(color: borderColor)), borderRadius: BorderRadius.vertical(top: Radius.circular(8)), ), child: Row( children: [ const Icon(Icons.settings, color: textMuted, size: 18), const SizedBox(width: 10), const Text('Settings', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: textPrimary)), const SizedBox(width: 12), Text(monthLabel, style: const TextStyle(fontSize: 13, color: textMuted)), const Spacer(), IconButton( icon: const Icon(Icons.close, color: textMuted, size: 18), onPressed: () { widget.onDataChanged(); Navigator.pop(context); }, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ], ), ), // Body Expanded( child: StreamBuilder>( stream: _settingsDao.watchPaymentRules(_monthPlanId), builder: (context, rulesSnap) { final rules = rulesSnap.data ?? []; final bills = widget.monthData.bills; // Compute live math sources + rule results final ruleBillIds = rules.map((r) => r.billDefinitionId).toSet(); final sources = computeMathSources( data: widget.monthData, ruleBillIds: ruleBillIds, bufferCents: _bufferCents, ); final billModes = {}; final billOutstanding = {}; for (final bill in bills) { billModes[bill.id] = _billModeOverrides[bill.id] ?? bill.paymentMode; billOutstanding[bill.id] = bill.outstandingBalanceCents; } final solverResult = solveRules( sources: sources, rules: rules, billModes: billModes, billOutstanding: billOutstanding, ); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildBufferSection(), const SizedBox(height: 20), _buildMathSourcesSection(sources), const SizedBox(height: 20), _buildPaymentRulesSection(rules, bills), const SizedBox(height: 20), _buildBillResultsSection(solverResult, bills), ], ), ); }, ), ), ], ), ), ); } // ── Safety Buffer (edits current month's buffer) ── Widget _buildBufferSection() { return _card( title: 'SAFETY BUFFER', child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ const Expanded( child: Text('Buffer amount for this month', style: TextStyle(fontSize: bodySize, color: textPrimary)), ), SizedBox( width: 120, child: InlineEditCell.cents( cents: _bufferCents, onSaved: (cents) { final clamped = cents < 0 ? 0 : cents; _monthDao.updateMonthPlan( _monthPlanId, MonthPlansCompanion(bufferAmountCents: Value(clamped)), ); setState(() => _bufferCents = clamped); widget.onDataChanged(); }, style: monoStyle, textAlign: TextAlign.left, ), ), ], ), ), ); } // ── Math Source Balances (live) ── Widget _buildMathSourcesSection(MathSourceValues sources) { return _card( title: 'MATH SOURCE BALANCES', subtitle: 'Live values — payment rules derive from these', child: Column( children: [ for (final ms in MathSource.values) ...[ if (ms.index > 0) const Divider(color: dividerColor, height: 1), _sourceRow(ms.label, sources[ms]), ], ], ), ); } Widget _sourceRow(String label, int cents) { final color = cents >= 0 ? accentGreen : accentRed; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Row( children: [ Expanded(child: Text(label, style: const TextStyle(fontSize: bodySize, color: textPrimary))), Text(formatCents(cents), style: TextStyle(fontSize: bodySize, fontFamily: monoFont, color: color)), ], ), ); } // ── Payment Rules ── Widget _buildPaymentRulesSection(List rules, List bills) { // Non-outside bills for the dropdown final selectableBills = bills.where((b) => !b.isOutside).toList(); return _card( title: 'PAYMENT RULES', subtitle: 'Processed top-to-bottom. Each rule consumes from its math source.', trailing: AddRowButton(label: 'Add Rule', onPressed: () async { final nextOrder = rules.isEmpty ? 0 : rules.last.sortOrder + 1; final defaultBillId = rules.isNotEmpty ? rules.last.billDefinitionId : (selectableBills.isNotEmpty ? selectableBills.first.id : 0); // Auto-pick type: if bill already has minimum, default to math var defaultType = 0; // minimum final billRuleTypes = rules .where((r) => r.billDefinitionId == defaultBillId) .map((r) => r.ruleType) .toSet(); if (billRuleTypes.contains(0)) defaultType = 1; // math await _settingsDao.addPaymentRule( PaymentRulesCompanion.insert( monthPlanId: _monthPlanId, billDefinitionId: defaultBillId, mathSource: 3, // Net − Buffer ruleType: defaultType, sortOrder: nextOrder, minimumAmountCents: defaultType == 0 ? const Value(0) : const Value.absent(), ), ); }), child: rules.isEmpty ? const Padding( padding: EdgeInsets.all(16), child: Text('No payment rules configured.', style: mutedStyle), ) : Column( children: [ _ruleHeader(), for (var i = 0; i < rules.length; i++) _ruleRow( rule: rules[i], index: i, rules: rules, selectableBills: selectableBills, ), ], ), ); } Widget _ruleHeader() { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: dividerColor)), ), child: const Row( children: [ SizedBox(width: 28), // reorder Expanded(flex: 4, child: Text('BILL', style: labelStyle)), SizedBox(width: 120, child: Text('SOURCE', style: labelStyle)), SizedBox(width: 100, child: Text('TYPE', style: labelStyle)), Expanded(flex: 2, child: Text('PARAMS', style: labelStyle)), SizedBox(width: 20), // remove ], ), ); } Widget _ruleRow({ required PaymentRule rule, required int index, required List rules, required List selectableBills, }) { final source = MathSource.values[rule.mathSource.clamp(0, 3)]; final type = RuleType.values[rule.ruleType.clamp(0, 2)]; // Determine which types are disabled for this bill (singleton per bill) final billRules = rules.where((r) => r.billDefinitionId == rule.billDefinitionId && r.id != rule.id); final usedTypes = billRules.map((r) => RuleType.values[r.ruleType.clamp(0, 2)]).toSet(); final disabledTypes = {}; if (usedTypes.contains(RuleType.minimum)) disabledTypes.add(RuleType.minimum); if (usedTypes.contains(RuleType.remainingBalance)) disabledTypes.add(RuleType.remainingBalance); // Find bill for display final bill = selectableBills.where((b) => b.id == rule.billDefinitionId).firstOrNull; return HoverBuilder( builder: (context, hovering) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12), constraints: const BoxConstraints(minHeight: rowHeight), decoration: BoxDecoration( color: hovering ? bgPayPeriod : Colors.transparent, border: const Border(bottom: BorderSide(color: dividerColor, width: 0.5)), ), child: Row( children: [ // Reorder arrows SizedBox( width: 28, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (index > 0) _arrowButton(Icons.arrow_drop_up, () async { final prev = rules[index - 1]; await _settingsDao.swapRuleOrder( rule.id, rule.sortOrder, prev.id, prev.sortOrder, ); }) else const SizedBox(height: 16), if (index < rules.length - 1) _arrowButton(Icons.arrow_drop_down, () async { final next = rules[index + 1]; await _settingsDao.swapRuleOrder( rule.id, rule.sortOrder, next.id, next.sortOrder, ); }) else const SizedBox(height: 16), ], ), ), // Bill selector (shows name + note) Expanded( flex: 4, child: Padding( padding: const EdgeInsets.only(right: 10), child: DropdownButtonHideUnderline( child: DropdownButton( value: bill != null ? rule.billDefinitionId : null, isDense: true, isExpanded: true, dropdownColor: bgCard, style: const TextStyle(fontSize: bodySize, color: textPrimary), hint: const Text('select bill', style: TextStyle(fontSize: 11, color: textDim)), icon: const Icon(Icons.arrow_drop_down, size: 16, color: textDim), items: selectableBills.map((b) { return DropdownMenuItem( value: b.id, child: Row( children: [ Text(b.name, style: const TextStyle(fontSize: bodySize, color: textPrimary)), if (b.subtitle != null) ...[ const SizedBox(width: 6), Flexible( child: Text( b.subtitle!, style: const TextStyle(fontSize: 11, color: textDim, fontStyle: FontStyle.italic), overflow: TextOverflow.ellipsis, ), ), ], ], ), ); }).toList(), onChanged: (v) { if (v != null) { // Auto-switch type if new bill already has this singleton type var newType = type; if (newType == RuleType.minimum || newType == RuleType.remainingBalance) { final newBillRules = rules.where((r) => r.billDefinitionId == v && r.id != rule.id); final newBillTypes = newBillRules.map((r) => RuleType.values[r.ruleType.clamp(0, 2)]).toSet(); if (newBillTypes.contains(newType)) { newType = RuleType.math; // safe: always allows multiple } } _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion( billDefinitionId: Value(v), ruleType: newType != type ? Value(newType.index) : const Value.absent(), ), ); } }, ), ), ), ), // Math source Padding( padding: const EdgeInsets.only(right: 10), child: SizedBox( width: 120, child: _dropdown( value: source, items: MathSource.values, labelBuilder: (v) => v.label, onChanged: (v) { if (v != null) { _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion(mathSource: Value(v.index)), ); } }, ), ), ), // Rule type (with disabled options) Padding( padding: const EdgeInsets.only(right: 10), child: SizedBox( width: 100, child: DropdownButtonHideUnderline( child: DropdownButton( value: type, isDense: true, isExpanded: true, dropdownColor: bgCard, style: const TextStyle(fontSize: bodySize, color: textPrimary), icon: const Icon(Icons.arrow_drop_down, size: 16, color: textDim), items: RuleType.values.map((rt) { final disabled = disabledTypes.contains(rt); return DropdownMenuItem( value: rt, enabled: !disabled, child: Text( rt.label, style: TextStyle( fontSize: bodySize, color: disabled ? textDim.withValues(alpha: 0.4) : textPrimary, ), ), ); }).toList(), onChanged: (v) { if (v != null) { _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion(ruleType: Value(v.index)), ); } }, ), ), ), ), // Type-specific params Expanded( flex: 2, child: _ruleParams(rule, type), ), // Remove (no confirmation) SizedBox( width: 20, child: hovering ? RemoveButton(onPressed: () => _settingsDao.deletePaymentRule(rule.id)) : const SizedBox.shrink(), ), ], ), ); }, ); } Widget _ruleParams(PaymentRule rule, RuleType type) { switch (type) { case RuleType.minimum: return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ const Text('at least ', style: TextStyle(fontSize: 11, color: textDim)), SizedBox( width: 80, child: InlineEditCell.cents( cents: rule.minimumAmountCents ?? 0, onSaved: (cents) => _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion(minimumAmountCents: Value(cents)), ), style: const TextStyle(fontSize: bodySize, fontFamily: monoFont, color: textPrimary), ), ), ], ); case RuleType.math: return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ SizedBox( width: 50, child: InlineEditCell( displayText: '${((rule.multiplier ?? 0) * 100).toStringAsFixed(1)}%', editText: ((rule.multiplier ?? 0) * 100).toStringAsFixed(1), onSaved: (text) { final pct = double.tryParse(text); if (pct != null) { _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion(multiplier: Value(pct / 100)), ); } }, validator: (text) => double.tryParse(text) == null ? 'Invalid' : null, style: const TextStyle(fontSize: bodySize, fontFamily: monoFont, color: textPrimary), keyboardType: const TextInputType.numberWithOptions(decimal: true), ), ), const Text(' + ', style: TextStyle(fontSize: 11, color: textDim)), SizedBox( width: 70, child: InlineEditCell.cents( cents: rule.additionCents ?? 0, onSaved: (cents) => _settingsDao.updatePaymentRule( rule.id, PaymentRulesCompanion(additionCents: Value(cents)), ), style: const TextStyle(fontSize: bodySize, fontFamily: monoFont, color: textPrimary), ), ), ], ); case RuleType.remainingBalance: return const Align( alignment: Alignment.centerRight, child: Text('pays outstanding', style: TextStyle(fontSize: 11, color: textDim)), ); } } // ── Bill Results (live) ── Widget _buildBillResultsSection(RuleSolverResult result, List bills) { if (result.billPayments.isEmpty) { return const SizedBox.shrink(); } final billInfos = <_BillInfo>[]; for (final entry in result.billPayments.entries) { final billId = entry.key; final bill = bills.where((b) => b.id == billId).firstOrNull; if (bill == null) continue; final allTypes = result.billAllRuleTypes[billId] ?? {}; final minAmount = result.billMinimums[billId] ?? 0; final payment = entry.value; final hasMath = allTypes.contains(RuleType.math) || allTypes.contains(RuleType.remainingBalance); final hasMin = allTypes.contains(RuleType.minimum); final canToggle = hasMin && hasMath; final mode = _billModeOverrides[bill.id] ?? bill.paymentMode; Color color; if (mode == 1) { color = accentBlue; } else if (hasMath && payment < minAmount) { color = accentAmber; } else if (hasMath) { color = accentGreen; } else { color = accentBlue; } billInfos.add(_BillInfo( billId: bill.id, name: bill.name, note: bill.subtitle, dueDay: bill.dueDay, paymentCents: payment, color: color, paymentMode: mode, canToggle: canToggle, steps: result.billSteps[billId] ?? [], )); } return _card( title: 'BILL PAYMENTS (LIVE)', subtitle: 'Computed from current month data with above rules applied', child: Column( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: dividerColor)), ), child: const Row( children: [ SizedBox(width: 16), Expanded(flex: 3, child: Text('BILL', style: labelStyle)), SizedBox(width: 100, child: Text('NOTE', style: labelStyle)), SizedBox(width: 40, child: Text('DUE', style: labelStyle)), SizedBox(width: 90, child: Text('CHARGE', style: labelStyle, textAlign: TextAlign.right)), SizedBox(width: 8), SizedBox(width: 42, child: Text('MODE', style: labelStyle)), ], ), ), for (final info in billInfos) _billResultRow(info), ], ), ); } String _daySuffix(int day) { if (day >= 11 && day <= 13) return 'th'; switch (day % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; } } Widget _billResultRow(_BillInfo info) { final expanded = _expandedBillIds.contains(info.billId); return Column( children: [ // Main row (clickable to expand) GestureDetector( onTap: info.steps.isNotEmpty ? () => setState(() { if (expanded) { _expandedBillIds.remove(info.billId); } else { _expandedBillIds.add(info.billId); } }) : null, child: MouseRegion( cursor: info.steps.isNotEmpty ? SystemMouseCursors.click : MouseCursor.defer, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), height: rowHeight, decoration: BoxDecoration( color: expanded ? bgPayPeriod : Colors.transparent, border: expanded ? null : const Border(bottom: BorderSide(color: dividerColor, width: 0.5)), ), child: Row( children: [ // Expand indicator SizedBox( width: 16, child: info.steps.isNotEmpty ? Icon( expanded ? Icons.expand_less : Icons.expand_more, size: 14, color: textDim, ) : const SizedBox.shrink(), ), Expanded( flex: 3, child: Text(info.name, style: const TextStyle(fontSize: bodySize, color: textPrimary)), ), SizedBox( width: 100, child: Text( info.note ?? '', style: const TextStyle(fontSize: smallLabelSize, color: textDim, fontStyle: FontStyle.italic), overflow: TextOverflow.ellipsis, ), ), SizedBox( width: 40, child: Text( '${info.dueDay}${_daySuffix(info.dueDay)}', style: const TextStyle(fontSize: smallLabelSize, color: textMuted), ), ), SizedBox( width: 90, child: Text( formatCents(info.paymentCents), style: TextStyle(fontSize: bodySize, fontFamily: monoFont, color: info.color), textAlign: TextAlign.right, ), ), const SizedBox(width: 8), SizedBox( width: 42, child: info.canToggle ? GestureDetector( onTap: () async { final newMode = info.paymentMode == 1 ? 0 : 1; await _monthDao.updateBill( info.billId, BillDefinitionsCompanion(paymentMode: Value(newMode)), ); setState(() => _billModeOverrides[info.billId] = newMode); widget.onDataChanged(); }, child: MouseRegion( cursor: SystemMouseCursors.click, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: info.color.withValues(alpha: 0.1), border: Border.all(color: info.color.withValues(alpha: 0.5)), borderRadius: BorderRadius.circular(3), ), child: Text( info.paymentMode == 1 ? 'min' : 'full', style: TextStyle(fontSize: 9, color: info.color), ), ), ), ), ) : const SizedBox.shrink(), ), ], ), ), ), ), // Expanded step breakdown if (expanded) ...info.steps.map((step) => _stepRow(step, info.color)), if (expanded) const Divider(color: dividerColor, height: 1), ], ); } Widget _stepRow(RuleStep step, Color billColor) { // Build rule description String ruleDesc; switch (step.type) { case RuleType.minimum: ruleDesc = 'Minimum ${formatCents(step.desiredCents)}'; case RuleType.math: final pct = ((step.multiplier ?? 0) * 100).toStringAsFixed(1); final add = step.additionCents ?? 0; ruleDesc = '× $pct% + ${formatCents(add)}'; case RuleType.remainingBalance: ruleDesc = 'Remaining balance'; } return Container( padding: const EdgeInsets.only(left: 28, right: 12), height: 26, decoration: const BoxDecoration( color: bgPayPeriod, border: Border(bottom: BorderSide(color: dividerColor, width: 0.3)), ), child: Row( children: [ // Source available SizedBox( width: 90, child: Text( formatCents(step.sourceAvailableCents), style: TextStyle(fontSize: 11, fontFamily: monoFont, color: textDim.withValues(alpha: 0.7)), ), ), // Rule description Expanded( child: Text( ruleDesc, style: const TextStyle(fontSize: 11, color: textMuted), overflow: TextOverflow.ellipsis, ), ), // Output SizedBox( width: 70, child: Text( '+${formatCents(step.actualCents)}', style: TextStyle(fontSize: 11, fontFamily: monoFont, color: billColor.withValues(alpha: 0.8)), textAlign: TextAlign.right, ), ), const SizedBox(width: 8), // Rolling total SizedBox( width: 70, child: Text( formatCents(step.rollingTotalCents), style: TextStyle(fontSize: 11, fontFamily: monoFont, fontWeight: FontWeight.w600, color: billColor), textAlign: TextAlign.right, ), ), const SizedBox(width: 50), // MODE column + gap space ], ), ); } // ── Shared helpers ── Widget _card({required String title, String? subtitle, Widget? trailing, required Widget child}) { return Container( decoration: BoxDecoration( color: bgCard, border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(6), ), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: labelStyle), if (subtitle != null) ...[ const SizedBox(height: 2), Text(subtitle, style: const TextStyle(fontSize: 11, color: textDim)), ], ], ), ), ?trailing, ], ), ), const SizedBox(height: 8), child, ], ), ); } Widget _arrowButton(IconData icon, VoidCallback onTap) { return GestureDetector( onTap: onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Icon(icon, size: 18, color: textDim), ), ); } Widget _dropdown({ required T? value, required List items, required String Function(T) labelBuilder, required ValueChanged onChanged, }) { return DropdownButtonHideUnderline( child: DropdownButton( value: value, isDense: true, isExpanded: true, dropdownColor: bgCard, style: const TextStyle(fontSize: bodySize, color: textPrimary), icon: const Icon(Icons.arrow_drop_down, size: 16, color: textDim), items: items.map((item) { return DropdownMenuItem( value: item, child: Text(labelBuilder(item), style: const TextStyle(fontSize: bodySize, color: textPrimary)), ); }).toList(), onChanged: onChanged, ), ); } } class _BillInfo { final int billId; final String name; final String? note; final int dueDay; final int paymentCents; final Color color; final int paymentMode; final bool canToggle; final List steps; const _BillInfo({ required this.billId, required this.name, this.note, required this.dueDay, required this.paymentCents, required this.color, required this.paymentMode, required this.canToggle, required this.steps, }); }