import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import '../database/daos/month_dao.dart'; import '../database/database.dart'; import '../services/rule_solver.dart'; import '../theme.dart'; import 'inline_edit_cell.dart'; class BillsPanel extends StatefulWidget { final List bills; final MonthDao monthDao; final int monthPlanId; final RuleSolverResult ruleResult; final VoidCallback onDataChanged; const BillsPanel({ super.key, required this.bills, required this.monthDao, required this.monthPlanId, required this.ruleResult, required this.onDataChanged, }); @override State createState() => _BillsPanelState(); } class _BillsPanelState extends State { int? _lastAddedId; Future _addBill() async { final id = await widget.monthDao.addBill( widget.monthPlanId, BillDefinitionsCompanion.insert( monthPlanId: widget.monthPlanId, name: 'New bill', dueDay: 1, defaultAmountCents: 0, sortOrder: widget.bills.length, isOutside: const Value(true), ), ); setState(() => _lastAddedId = id); widget.onDataChanged(); } Future _deleteBill(int id) async { await widget.monthDao.deleteBill(id); widget.onDataChanged(); } @override Widget build(BuildContext context) { // Outside and auto-pay both count as "paid" final paidCount = widget.bills.where((b) => b.isPaid || b.isAutoPay || b.isOutside).length; return Container( decoration: BoxDecoration( color: bgCard, border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(6), ), clipBehavior: Clip.antiAlias, child: HoverBuilder(builder: (context, sectionHovering) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: cardPadding, vertical: 8), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: dividerColor))), child: Row( children: [ const Text('BILLS CONFIG', style: TextStyle(fontSize: labelSize, color: textMuted, letterSpacing: 0.5)), const Spacer(), Text( '$paidCount/${widget.bills.length} paid', style: TextStyle( fontSize: labelSize, color: paidCount == widget.bills.length ? accentGreen : textDim, ), ), ], ), ), if (widget.bills.isEmpty) const Padding( padding: EdgeInsets.all(cardPadding), child: Text('No bills yet.', style: TextStyle(fontSize: bodySize, color: textDim)), ) else for (var i = 0; i < widget.bills.length; i++) _BillRow( bill: widget.bills[i], monthDao: widget.monthDao, ruleResult: widget.ruleResult, onDataChanged: widget.onDataChanged, onDelete: () => _deleteBill(widget.bills[i].id), autoEditName: widget.bills[i].id == _lastAddedId, isLast: i == widget.bills.length - 1, ), if (sectionHovering) AddRowButton(label: 'Add bill', onPressed: _addBill) else const SizedBox(height: 4), ], ); }), ); } @override void didUpdateWidget(BillsPanel oldWidget) { super.didUpdateWidget(oldWidget); if (_lastAddedId != null && widget.bills.any((b) => b.id == _lastAddedId)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _lastAddedId = null); }); } } } class _BillRow extends StatelessWidget { final BillDefinition bill; final MonthDao monthDao; final RuleSolverResult ruleResult; final VoidCallback onDataChanged; final VoidCallback onDelete; final bool autoEditName; final bool isLast; const _BillRow({ required this.bill, required this.monthDao, required this.ruleResult, required this.onDataChanged, required this.onDelete, this.autoEditName = false, this.isLast = false, }); /// Whether this bill has any payment rules applied (by bill ID). bool get _hasRules => ruleResult.billPayments.containsKey(bill.id); /// The rule-computed payment for this bill. int get _rulePayment => ruleResult.billPayments[bill.id] ?? 0; /// ALL configured rule types for this bill (regardless of payment mode). Set get _allRuleTypes => ruleResult.billAllRuleTypes[bill.id] ?? {}; /// Whether this bill can toggle between min and full mode. /// Requires BOTH minimum AND (math or remaining) rules. bool get _canToggleMode { final types = _allRuleTypes; return types.contains(RuleType.minimum) && (types.contains(RuleType.math) || types.contains(RuleType.remainingBalance)); } /// Color based on which rule types contributed to the payment. /// Orange (under minimum) > Green (math/remaining) > Blue (minimum only) Color get _ruleColor { final types = _allRuleTypes; final minAmount = ruleResult.billMinimums[bill.id] ?? 0; final hasMath = types.contains(RuleType.math) || types.contains(RuleType.remainingBalance); if (bill.paymentMode == 1) return accentBlue; // min-only mode if (hasMath && _rulePayment < minAmount) return accentAmber; if (hasMath) return accentGreen; return accentBlue; } 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'; } } Future _cycleStatus() async { if (bill.isOutside) { await monthDao.updateBill(bill.id, BillDefinitionsCompanion( isOutside: Value(false), isAutoPay: Value(true), )); } else if (bill.isAutoPay) { await monthDao.updateBill(bill.id, BillDefinitionsCompanion( isAutoPay: Value(false), )); } else { await monthDao.updateBill(bill.id, BillDefinitionsCompanion( isOutside: Value(true), )); } onDataChanged(); } Widget _statusTag() { final String label; final Color color; if (bill.isOutside) { label = 'outside'; color = accentBlue; } else if (bill.isAutoPay) { label = 'auto'; color = accentGreen; } else { label = 'manual'; color = textDim; } return GestureDetector( onTap: _cycleStatus, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( width: 50, alignment: Alignment.center, margin: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( border: Border.all(color: color), borderRadius: BorderRadius.circular(3), ), child: Text(label, style: TextStyle(fontSize: 9, color: color)), ), ), ); } @override Widget build(BuildContext context) { return HoverBuilder(builder: (context, hovering) { return Container( height: rowHeight, padding: const EdgeInsets.symmetric(horizontal: cardPadding), decoration: isLast ? null : const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFF1a1a2e)))), child: Row( children: [ _PriorityCycler( priority: bill.priority, onCycle: () async { final next = (bill.priority + 1) % 3; await monthDao.updateBill(bill.id, BillDefinitionsCompanion(priority: Value(next))); onDataChanged(); }, ), const SizedBox(width: 6), _statusTag(), const SizedBox(width: 6), Expanded( child: Row( children: [ Flexible( child: InlineEditCell( displayText: bill.name, editText: bill.name, autoEdit: autoEditName, onSaved: (text) async { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(name: Value(text))); onDataChanged(); }, style: const TextStyle(fontSize: bodySize, color: textPrimary), ), ), if (bill.isFreeform) GestureDetector( onTap: () async { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(isFreeform: Value(false))); onDataChanged(); }, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( margin: const EdgeInsets.only(left: 4), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( border: Border.all(color: accentAmber), borderRadius: BorderRadius.circular(3), ), child: const Text('CC', style: TextStyle(fontSize: 9, color: accentAmber)), ), ), ), if (bill.subtitle != null || hovering) Flexible( child: Padding( padding: const EdgeInsets.only(left: 6), child: InlineEditCell( displayText: bill.subtitle ?? (hovering ? 'note...' : ''), editText: bill.subtitle ?? '', onSaved: (text) async { await monthDao.updateBill( bill.id, BillDefinitionsCompanion(subtitle: Value(text.isEmpty ? null : text)), ); onDataChanged(); }, style: TextStyle( fontSize: labelSize, color: bill.subtitle != null ? textDim : textDim.withValues(alpha: 0.4), fontStyle: FontStyle.italic, ), ), ), ), ], ), ), // Due day SizedBox( width: 36, child: InlineEditCell( displayText: '${bill.dueDay}${_daySuffix(bill.dueDay)}', editText: bill.dueDay.toString(), onSaved: (text) async { final day = int.tryParse(text); if (day != null && day >= 1 && day <= 31) { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(dueDay: Value(day))); onDataChanged(); } }, validator: (text) { final day = int.tryParse(text); return (day == null || day < 1 || day > 31) ? 'Invalid' : null; }, style: const TextStyle(fontSize: labelSize, color: textMuted), keyboardType: TextInputType.number, ), ), // Amount — rule-computed (colored, non-editable) or manual (editable) SizedBox( width: 70, child: _hasRules ? GestureDetector( onTap: _canToggleMode ? () async { final newMode = bill.paymentMode == 1 ? 0 : 1; await monthDao.updateBill(bill.id, BillDefinitionsCompanion(paymentMode: Value(newMode))); onDataChanged(); } : null, child: MouseRegion( cursor: _canToggleMode ? SystemMouseCursors.click : MouseCursor.defer, child: Text( formatCents(_rulePayment), style: TextStyle(fontSize: bodySize, fontFamily: monoFont, color: _ruleColor), textAlign: TextAlign.right, ), ), ) : InlineEditCell.cents( cents: bill.amountOverrideCents ?? bill.defaultAmountCents, onSaved: (cents) async { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(defaultAmountCents: Value(cents))); onDataChanged(); }, style: const TextStyle(fontSize: bodySize, fontFamily: monoFont, color: textPrimary), ), ), const SizedBox(width: 4), // Balance cell: always editable SizedBox( width: 84, child: Row( children: [ Expanded(child: _buildEditableBalance(hovering)), if (hovering) Padding( padding: const EdgeInsets.only(left: 4), child: RemoveButton(onPressed: onDelete), ), ], ), ), ], ), ); }); } Widget _buildEditableBalance(bool hovering) { if (bill.outstandingBalanceCents != null) { return InlineEditCell( displayText: formatCents(bill.outstandingBalanceCents!), editText: (bill.outstandingBalanceCents! / 100).toStringAsFixed(2), onSaved: (text) async { if (text.isEmpty) { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(outstandingBalanceCents: const Value(null))); } else { final parsed = double.tryParse(text); if (parsed != null) { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(outstandingBalanceCents: Value((parsed * 100).round()))); } } onDataChanged(); }, style: const TextStyle(fontSize: smallLabelSize, fontFamily: monoFont, color: textDim), textAlign: TextAlign.right, keyboardType: const TextInputType.numberWithOptions(decimal: true), ); } if (hovering) { return GestureDetector( onTap: () async { await monthDao.updateBill(bill.id, BillDefinitionsCompanion(outstandingBalanceCents: Value(0))); onDataChanged(); }, child: const MouseRegion( cursor: SystemMouseCursors.click, child: Text('+ bal', textAlign: TextAlign.right, style: TextStyle(fontSize: smallLabelSize, color: textDim)), ), ); } return const SizedBox(); } } class _PriorityCycler extends StatefulWidget { final int priority; final VoidCallback onCycle; const _PriorityCycler({required this.priority, required this.onCycle}); @override State<_PriorityCycler> createState() => _PriorityCyclerState(); } class _PriorityCyclerState extends State<_PriorityCycler> { bool _hovering = false; Widget _priorityShape(int priority) { const size = 8.0; final alpha = _hovering ? 0.7 : 1.0; switch (priority) { case 1: return Transform.rotate( angle: 0.785398, child: Container(width: size, height: size, color: accentAmber.withValues(alpha: alpha)), ); case 2: return Container(width: size, height: size, color: accentRed.withValues(alpha: alpha)); default: return Container( width: size, height: size, decoration: BoxDecoration(color: accentGreen.withValues(alpha: alpha), shape: BoxShape.circle), ); } } @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onCycle, child: MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), cursor: SystemMouseCursors.click, child: SizedBox( width: 16, child: Center(child: _priorityShape(widget.priority)), ), ), ); } }