import 'package:flutter/material.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; class BudgetPage extends StatefulWidget { const BudgetPage({super.key}); @override State createState() => _BudgetPageState(); } class _BudgetPageState extends State { BudgetsData? _data; List _categories = []; bool _loading = true; bool _aiLoading = false; DateTime _month = DateTime(ShanghaiTime.now.year, ShanghaiTime.now.month); @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); try { final results = await Future.wait([ BudgetApi.get(_month.year, _month.month), TxApi.categories('expense'), ]); if (!mounted) return; setState(() { _data = results[0] as BudgetsData; _categories = results[1] as List; }); } catch (e) { if (mounted) _showError(e); } finally { if (mounted) setState(() => _loading = false); } } Future _changeMonth(int offset) async { setState(() { _month = DateTime(_month.year, _month.month + offset); _data = null; }); await _load(); } Future _edit({ int? categoryId, String? categoryName, double? current, bool recurring = false, }) async { final controller = TextEditingController( text: current == null || current == 0 ? '' : current.toStringAsFixed(0), ); var repeats = recurring; final result = await showModalBottomSheet<({double amount, bool recurring})>( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) => StatefulBuilder( builder: (context, setSheetState) => SafeArea( child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: Container( padding: const EdgeInsets.fromLTRB(20, 0, 20, 18), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.vertical( top: Radius.circular(24), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ JzSheetHeader( title: categoryId == null ? '总预算' : '“' + (categoryName ?? '') + '”预算', subtitle: '输入 0 可以删除当前预算', ), SizedBox(height: 16), TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), autofocus: true, decoration: InputDecoration( prefixText: '¥ ', hintText: '输入预算金额', ), ), SizedBox(height: 12), InkWell( onTap: () => setSheetState(() => repeats = !repeats), borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.symmetric( horizontal: 13, vertical: 9, ), decoration: BoxDecoration( color: repeats ? context.jz.primaryBackground : context.jz.background, borderRadius: BorderRadius.circular(14), border: Border.all( color: repeats ? AppTheme.primary : context.jz.line, ), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '每月自动续期', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), SizedBox(height: 3), Text( '以后每个月沿用,可随时单独调整', style: TextStyle( fontSize: 10.5, color: context.jz.text3, ), ), ], ), ), Switch( value: repeats, onChanged: (value) => setSheetState(() => repeats = value), ), ], ), ), ), SizedBox(height: 18), Row( children: [ Expanded( child: JzActionButton( label: '取消', secondary: true, onPressed: () => Navigator.pop(sheetContext), ), ), SizedBox(width: 10), Expanded( child: JzActionButton( label: '保存', onPressed: () => Navigator.pop(sheetContext, ( amount: double.tryParse(controller.text.trim()) ?? 0, recurring: repeats, )), ), ), ], ), ], ), ), ), ), ), ); controller.dispose(); if (result == null) return; try { await BudgetApi.upsert( _month.year, _month.month, categoryId: categoryId, amount: result.amount, recurring: result.recurring, ); await _load(); } catch (error) { if (mounted) _showError(error); } } Future _openAiRecommendations() async { if (_aiLoading) return; setState(() => _aiLoading = true); try { var recommendation = await BudgetApi.recommendations( _month.year, _month.month, ); if (!mounted) return; if (recommendation.items.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(recommendation.message))); return; } final totalController = TextEditingController( text: recommendation.suggestedTotal.toStringAsFixed(0), ); final controllers = { for (final item in recommendation.items) item.categoryId: TextEditingController( text: item.suggestedAmount.toStringAsFixed(0), ), }; var recurring = true; var refining = false; final instructionController = TextEditingController(); final history = []; Future refineDraft( BuildContext sheetContext, StateSetter setSheetState, ) async { final instruction = instructionController.text.trim(); if (instruction.isEmpty || refining) return; setSheetState(() => refining = true); try { final updated = await BudgetApi.refineRecommendation( _month.year, _month.month, instruction: instruction, suggestedTotal: double.tryParse(totalController.text.trim()) ?? recommendation.suggestedTotal, amounts: { for (final item in recommendation.items) item.categoryId: double.tryParse( controllers[item.categoryId]?.text.trim() ?? '', ) ?? item.suggestedAmount, }, history: history, ); if (!sheetContext.mounted) return; for (final item in updated.items) { final controller = controllers.putIfAbsent( item.categoryId, () => TextEditingController(), ); controller.text = item.suggestedAmount.toStringAsFixed(0); } totalController.text = updated.suggestedTotal.toStringAsFixed(0); final assistantText = updated.adjustmentSummary.isNotEmpty ? updated.adjustmentSummary : '预算草稿已按要求调整'; setSheetState(() { recommendation = updated; history.add( BudgetRefinementTurn(role: 'user', content: instruction), ); history.add( BudgetRefinementTurn(role: 'assistant', content: assistantText), ); if (history.length > 12) { history.removeRange(0, history.length - 12); } instructionController.clear(); }); } catch (error) { if (sheetContext.mounted) { ScaffoldMessenger.of( sheetContext, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); } } finally { if (sheetContext.mounted) { setSheetState(() => refining = false); } } } final result = await showModalBottomSheet< ({bool recurring, Map amounts}) >( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) => StatefulBuilder( builder: (context, setSheetState) => FractionallySizedBox( heightFactor: 0.9, child: Material( color: Theme.of(context).scaffoldBackgroundColor, borderRadius: const BorderRadius.vertical( top: Radius.circular(24), ), clipBehavior: Clip.antiAlias, child: SafeArea( top: false, child: Column( children: [ SizedBox(height: 10), Container( width: 38, height: 4, decoration: BoxDecoration( color: context.jz.line, borderRadius: BorderRadius.circular(2), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 10), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: context.jz.aiBackground, shape: BoxShape.circle, ), child: Center( child: AppIcons.icon( AppIcons.sparkle, size: 17, color: AppTheme.ai, ), ), ), SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'AI 预算建议', style: TextStyle( fontSize: 17, fontWeight: FontWeight.w800, ), ), Text( '确认前可以修改每一项金额', style: TextStyle( fontSize: 11.5, color: context.jz.text3, ), ), ], ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: context.jz.aiBackground, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( recommendation.adjustmentSummary.isNotEmpty ? recommendation.adjustmentSummary : recommendation.message, style: TextStyle( fontSize: 12, height: 1.45, color: context.jz.text2, ), ), ...recommendation.warnings.map( (warning) => Padding( padding: const EdgeInsets.only(top: 5), child: Text( '· $warning', style: TextStyle( fontSize: 11, color: AppTheme.orange, ), ), ), ), ], ), ), ), SizedBox(height: 8), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), children: [ ...history.map( (turn) => Align( alignment: turn.role == 'user' ? Alignment.centerRight : Alignment.centerLeft, child: Container( constraints: const BoxConstraints( maxWidth: 280, ), margin: const EdgeInsets.only(bottom: 7), padding: const EdgeInsets.symmetric( horizontal: 11, vertical: 8, ), decoration: BoxDecoration( color: turn.role == 'user' ? AppTheme.ai : Colors.white, borderRadius: BorderRadius.circular(12), border: turn.role == 'user' ? null : Border.all(color: context.jz.line), ), child: Text( turn.content, style: TextStyle( fontSize: 11.5, color: turn.role == 'user' ? Colors.white : context.jz.text2, ), ), ), ), ), _recommendationRow( iconKey: 'wallet', name: '本月总预算', detail: '分类建议合计', controller: totalController, emphasized: true, ), ...recommendation.items.map( (item) => _recommendationRow( iconKey: item.categoryIcon, colorKey: item.colorKey, name: item.categoryName, detail: '近月均值 ¥${item.historicalAverage.toStringAsFixed(0)} · 已花 ¥${item.currentSpent.toStringAsFixed(0)} · ${_confidenceText(item.confidence)}', controller: controllers[item.categoryId]!, ), ), JzSwitchTile( value: recurring, title: '设为周期预算', subtitle: '每月自动沿用这套预算', onChanged: (value) => setSheetState(() => recurring = value), ), SizedBox(height: 4), Container( padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line), ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: instructionController, enabled: !refining, minLines: 1, maxLines: 3, maxLength: 500, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, counterText: '', hintText: '继续调整,如:餐饮减少 200', contentPadding: EdgeInsets.symmetric( vertical: 8, ), ), ), ), SizedBox(width: 8), Material( color: refining ? context.jz.line : AppTheme.ai, borderRadius: BorderRadius.circular(11), child: InkWell( onTap: refining ? null : () => refineDraft( sheetContext, setSheetState, ), borderRadius: BorderRadius.circular(11), child: SizedBox( width: 46, height: 40, child: Center( child: refining ? SizedBox( width: 17, height: 17, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : AppIcons.icon( AppIcons.sparkle, size: 17, color: Colors.white, ), ), ), ), ), ], ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), child: SizedBox( width: double.infinity, height: 46, child: FilledButton( onPressed: refining ? null : () { final amounts = { null: double.tryParse( totalController.text, ) ?? 0, for (final item in recommendation.items) item.categoryId: double.tryParse( controllers[item.categoryId]! .text, ) ?? 0, }; Navigator.pop(sheetContext, ( recurring: recurring, amounts: amounts, )); }, child: Text('应用这套预算'), ), ), ), ], ), ), ), ), ), ); totalController.dispose(); for (final controller in controllers.values) { controller.dispose(); } instructionController.dispose(); if (result == null) return; await BudgetApi.applyBatch( _month.year, _month.month, recurring: result.recurring, amounts: result.amounts, ); await _load(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('预算方案已应用'))); } } catch (e) { if (mounted) _showError(e); } finally { if (mounted) setState(() => _aiLoading = false); } } Widget _recommendationRow({ required String iconKey, String? colorKey, required String name, required String detail, required TextEditingController controller, bool emphasized = false, }) { return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: emphasized ? context.jz.aiBackground : context.jz.card, borderRadius: BorderRadius.circular(13), border: Border.all(color: context.jz.line), ), child: Row( children: [ CategoryIconBox(iconKey: iconKey, colorKey: colorKey, size: 36), SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w700), ), SizedBox(height: 2), Text( detail, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10.5, color: context.jz.text3), ), ], ), ), SizedBox(width: 8), SizedBox( width: 86, child: TextField( controller: controller, textAlign: TextAlign.end, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: InputDecoration( prefixText: '¥', isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 8, vertical: 9, ), ), ), ), ], ), ); } @override Widget build(BuildContext context) { final data = _data; final total = data?.total; return Scaffold( appBar: AppBar(title: Text('预算管理')), body: _loading && data == null ? Center( child: CircularProgressIndicator( color: AppTheme.primary, strokeWidth: 2, ), ) : RefreshIndicator( onRefresh: _load, child: ListView( padding: const EdgeInsets.all(16), children: [ _monthNavigator(), SizedBox(height: 12), _totalCard(total), SizedBox(height: 10), _budgetActions(data), SizedBox(height: 10), _categoryCard(data), ], ), ), ); } Widget _monthNavigator() { final now = ShanghaiTime.now; final canGoNext = _month.year < now.year || (_month.year == now.year && _month.month < now.month); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( onPressed: () => _changeMonth(-1), icon: Icon(Icons.chevron_left_rounded), ), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(18), ), child: Text( '${_month.year} 年 ${_month.month} 月', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), ), ), IconButton( onPressed: canGoNext ? () => _changeMonth(1) : null, icon: Icon(Icons.chevron_right_rounded), ), ], ); } Widget _totalCard(BudgetItem? total) { return Card( child: InkWell( onTap: () => _edit( current: total?.amount, recurring: total?.isRecurring ?? false, ), borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.all(16), child: total == null ? Padding( padding: EdgeInsets.symmetric(vertical: 18), child: Center( child: Text( '点击设置总预算', style: TextStyle(fontSize: 13, color: context.jz.text2), ), ), ) : Row( children: [ SizedBox( width: 92, height: 92, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 92, height: 92, child: CircularProgressIndicator( value: total.ratio, strokeWidth: 9, backgroundColor: context.jz.line, color: total.ratio > 0.85 ? AppTheme.red : AppTheme.primary, ), ), Column( mainAxisSize: MainAxisSize.min, children: [ Text( '已用', style: TextStyle( fontSize: 9.5, color: context.jz.text3, ), ), Text( '${(total.ratio * 100).toStringAsFixed(0)}%', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, ), ), ], ), ], ), ), SizedBox(width: 18), Expanded( child: Column( children: [ _keyValue( total.isRecurring ? '周期总预算' : '本月总预算', '¥${total.amount.toStringAsFixed(2)}', ), _keyValue( '已使用', '¥${total.spent.toStringAsFixed(2)}', ), _keyValue( '剩余可用', '¥${(total.amount - total.spent).toStringAsFixed(2)}', color: AppTheme.primary, ), ], ), ), ], ), ), ), ); } Widget _categoryCard(BudgetsData? data) { final budgets = data?.categories ?? const []; return Card( child: Column( children: [ ...budgets.map( (budget) => InkWell( onTap: () => _edit( categoryId: budget.categoryId, categoryName: budget.categoryName, current: budget.amount, recurring: budget.isRecurring, ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 11, ), child: Row( children: [ CategoryIconBox( iconKey: budget.categoryIcon ?? 'tag', colorKey: budget.categoryColor, size: 34, ), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( budget.categoryName ?? '', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, ), ), ), if (budget.isRecurring) ...[ SizedBox(width: 5), _tag('每月'), ], Spacer(), Text( '¥${budget.spent.toStringAsFixed(0)} / ¥${budget.amount.toStringAsFixed(0)}', style: TextStyle( fontSize: 11.5, color: context.jz.text3, ), ), ], ), SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: budget.ratio, minHeight: 4, backgroundColor: context.jz.background, color: budget.ratio > 0.85 ? AppTheme.red : budget.ratio > 0.7 ? AppTheme.orange : AppTheme.primary, ), ), ], ), ), ], ), ), ), ), ], ), ); } Widget _budgetActions(BudgetsData? data) { final budgets = data?.categories ?? const []; final existing = budgets.map((budget) => budget.categoryId).toSet(); final canAdd = _categories.any( (category) => !existing.contains(category.id), ); return Row( children: [ if (SessionStore.instance.aiEnabled) ...[ Expanded( child: _BudgetActionCard( label: _aiLoading ? '正在分析…' : 'AI 订预算', description: '参考近三个月消费', icon: AppIcons.sparkle, foreground: AppTheme.ai, background: context.jz.aiBackground, loading: _aiLoading, onTap: _aiLoading ? null : _openAiRecommendations, ), ), SizedBox(width: 10), ], Expanded( child: _BudgetActionCard( label: '添加分类预算', description: canAdd ? '为单项消费设上限' : '分类预算已全部设置', icon: AppIcons.plus, foreground: AppTheme.primaryDeep, background: context.jz.primaryBackground, onTap: canAdd ? () => _addCategoryBudget(budgets) : null, ), ), ], ); } Future _addCategoryBudget(List budgets) async { final existing = budgets.map((budget) => budget.categoryId).toSet(); final candidates = _categories .where((category) => !existing.contains(category.id)) .toList(); if (candidates.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('所有支出分类都已设置预算'))); return; } final category = await showJzOptionSheet( context, title: '添加分类预算', subtitle: '选择一个尚未设置预算的支出分类', options: candidates .map( (item) => JzOption( value: item, label: item.name, leading: CategoryIconBox( iconKey: item.iconKey, colorKey: item.colorKey, size: 34, ), ), ) .toList(), ); if (category != null) { await _edit(categoryId: category.id, categoryName: category.name); } } Widget _tag(String text) => Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: context.jz.aiBackground, borderRadius: BorderRadius.circular(6), ), child: Text( text, style: TextStyle( fontSize: 9, color: AppTheme.ai, fontWeight: FontWeight.w700, ), ), ); String _confidenceText(String confidence) => switch (confidence) { 'high' => '依据充分', 'medium' => '依据一般', _ => '初步估算', }; Widget _keyValue(String key, String value, {Color? color}) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Text(key, style: TextStyle(fontSize: 12, color: context.jz.text2)), Spacer(), Text( value, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, color: color ?? context.jz.text, ), ), ], ), ); void _showError(Object error) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); } } class _BudgetActionCard extends StatelessWidget { final String label; final String description; final String icon; final Color foreground; final Color background; final bool loading; final VoidCallback? onTap; const _BudgetActionCard({ required this.label, required this.description, required this.icon, required this.foreground, required this.background, this.loading = false, this.onTap, }); @override Widget build(BuildContext context) { final enabled = onTap != null; return Semantics( button: true, enabled: enabled, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: AnimatedOpacity( duration: const Duration(milliseconds: 180), opacity: enabled ? 1 : 0.48, child: Container( height: 68, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(14), border: Border.all(color: foreground.withValues(alpha: 0.16)), ), child: Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(10), ), child: Center( child: loading ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: foreground, ), ) : AppIcons.icon(icon, size: 17, color: foreground), ), ), SizedBox(width: 9), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, color: foreground, fontWeight: FontWeight.w800, ), ), SizedBox(height: 3), Text( description, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 9.5, color: context.jz.text3, ), ), ], ), ), ], ), ), ), ), ); } }