1124 lines
43 KiB
Dart
1124 lines
43 KiB
Dart
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<BudgetPage> createState() => _BudgetPageState();
|
|
}
|
|
|
|
class _BudgetPageState extends State<BudgetPage> {
|
|
BudgetsData? _data;
|
|
List<CategoryItem> _categories = [];
|
|
bool _loading = true;
|
|
bool _aiLoading = false;
|
|
DateTime _month = DateTime(ShanghaiTime.now.year, ShanghaiTime.now.month);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
final results = await Future.wait<dynamic>([
|
|
BudgetApi.get(_month.year, _month.month),
|
|
TxApi.categories('expense'),
|
|
]);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_data = results[0] as BudgetsData;
|
|
_categories = results[1] as List<CategoryItem>;
|
|
});
|
|
} catch (e) {
|
|
if (mounted) _showError(e);
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _changeMonth(int offset) async {
|
|
setState(() {
|
|
_month = DateTime(_month.year, _month.month + offset);
|
|
_data = null;
|
|
});
|
|
await _load();
|
|
}
|
|
|
|
Future<void> _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<void> _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 = <int, TextEditingController>{
|
|
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 = <BudgetRefinementTurn>[];
|
|
|
|
Future<void> 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<int?, double> 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 = <int?, double>{
|
|
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 <BudgetItem>[];
|
|
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 <BudgetItem>[];
|
|
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<void> _addCategoryBudget(List<BudgetItem> 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<CategoryItem>(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|