1001 lines
34 KiB
Dart
1001 lines
34 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
|
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
|
import 'package:miaoji_zhang/shared/api/config_api.dart';
|
|
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
|
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
|
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
|
|
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
|
|
/// 首页明细(P1 / P20 空状态)
|
|
class HomePage extends StatefulWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
State<HomePage> createState() => HomePageState();
|
|
}
|
|
|
|
class HomePageState extends State<HomePage> {
|
|
MonthSummary? _summary;
|
|
BudgetsData? _budgets;
|
|
bool _loading = true;
|
|
String? _error;
|
|
late DateTime _month;
|
|
int _loadRevision = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_month = ShanghaiTime.now;
|
|
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
|
TransactionEvents.revision.addListener(_refreshTransactions);
|
|
CurrentLedgerStore.instance.addListener(_refreshLedger);
|
|
unawaited(refresh());
|
|
}
|
|
|
|
void _refreshCompanion() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _refreshTransactions() {
|
|
if (mounted) refresh();
|
|
}
|
|
|
|
void _refreshLedger() {
|
|
if (mounted) refresh();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
PublicConfigApi.companionNotifier.removeListener(_refreshCompanion);
|
|
TransactionEvents.revision.removeListener(_refreshTransactions);
|
|
CurrentLedgerStore.instance.removeListener(_refreshLedger);
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
final revision = ++_loadRevision;
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await CurrentLedgerStore.instance.loadCached();
|
|
final localSummary = TxApi.monthLocal(_month.year, _month.month);
|
|
final localBudgets = BudgetApi.getLocal(_month.year, _month.month);
|
|
if (mounted && revision == _loadRevision) {
|
|
setState(() {
|
|
_summary = localSummary;
|
|
_budgets = localBudgets;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (mounted && revision == _loadRevision) {
|
|
setState(() {
|
|
_error = apiErrorMessage(error);
|
|
_loading = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
await Future.wait([
|
|
() async {
|
|
try {
|
|
await CurrentLedgerStore.instance.refreshRemote();
|
|
} catch (_) {}
|
|
}(),
|
|
() async {
|
|
try {
|
|
final summary = await TxApi.monthRemote(_month.year, _month.month);
|
|
if (mounted && revision == _loadRevision) {
|
|
setState(() => _summary = summary);
|
|
}
|
|
} catch (_) {}
|
|
}(),
|
|
() async {
|
|
try {
|
|
final budgets = await BudgetApi.getRemote(_month.year, _month.month);
|
|
if (mounted && revision == _loadRevision) {
|
|
setState(() => _budgets = budgets);
|
|
}
|
|
} catch (_) {}
|
|
}(),
|
|
]);
|
|
if (mounted && revision == _loadRevision) {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = _summary;
|
|
final budgets = _budgets;
|
|
final session = SessionStore.instance;
|
|
final showAiEntry = session.isGuest || session.aiEnabled;
|
|
final budgetsByCategory = <int, BudgetItem>{
|
|
for (final item in budgets?.categories ?? const <BudgetItem>[])
|
|
if (item.categoryId != null) item.categoryId!: item,
|
|
};
|
|
if (!_loading && s == null && _error != null) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: AsyncErrorView(message: _error!, onRetry: refresh),
|
|
),
|
|
);
|
|
}
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: RefreshIndicator(
|
|
onRefresh: refresh,
|
|
color: AppTheme.primary,
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
if (notification is ScrollStartNotification) {
|
|
_SwipeReveal.closeOpen();
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
children: [
|
|
SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () {
|
|
_month = DateTime(_month.year, _month.month - 1);
|
|
refresh();
|
|
},
|
|
child: Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.rotationY(3.1416),
|
|
child: AppIcons.icon(
|
|
AppIcons.chevronRight,
|
|
size: 16,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 6),
|
|
GestureDetector(
|
|
onTap: () =>
|
|
LedgerSheet.show(context).then((_) => refresh()),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
CurrentLedgerStore.instance.currentName,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
AppIcons.icon(
|
|
AppIcons.chevronDown,
|
|
size: 16,
|
|
color: context.jz.text3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 6),
|
|
GestureDetector(
|
|
onTap: () {
|
|
_month = DateTime(_month.year, _month.month + 1);
|
|
refresh();
|
|
},
|
|
child: AppIcons.icon(
|
|
AppIcons.chevronRight,
|
|
size: 16,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
Spacer(),
|
|
BackendStatusIcon(onRetry: refresh),
|
|
IconButton(
|
|
icon: AppIcons.icon(
|
|
AppIcons.search,
|
|
size: 20,
|
|
color: context.jz.text2,
|
|
),
|
|
onPressed: () => context.push('/search'),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
'${_month.year}年${_month.month}月',
|
|
style: TextStyle(fontSize: 11, color: context.jz.text3),
|
|
),
|
|
SizedBox(height: 6),
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primary,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${_month.month}月结余',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
),
|
|
),
|
|
SizedBox(height: 5),
|
|
Text(
|
|
'¥ ${(s?.balance ?? 0).toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 30,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
_HeroCol(label: '收入', value: s?.income ?? 0),
|
|
_HeroCol(label: '支出', value: s?.expense ?? 0),
|
|
_HeroCol(
|
|
label: '笔数',
|
|
value: (s?.count ?? 0).toDouble(),
|
|
isCount: true,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 12),
|
|
if (budgets != null) ...[
|
|
_BudgetSummaryCard(
|
|
data: budgets,
|
|
onTap: () async {
|
|
await context.push('/budget');
|
|
refresh();
|
|
},
|
|
),
|
|
SizedBox(height: 12),
|
|
],
|
|
if (showAiEntry) ...[
|
|
GestureDetector(
|
|
onTap: () => context.go('/chat'),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 13,
|
|
vertical: 11,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 19,
|
|
backgroundColor: AppTheme.ai,
|
|
child: AppIcons.icon(
|
|
AppIcons.avatarAsset(
|
|
PublicConfigApi.companionAvatarKey,
|
|
),
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(width: 11),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
PublicConfigApi.companionName,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
SessionStore.instance.shouldUseLocalOnly
|
|
? '登录并连接云端后可使用 AI 记账'
|
|
: '说句话就能记账',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: context.jz.text2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.aiBackground,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Text(
|
|
'去聊天',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.ai,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 15),
|
|
],
|
|
if (_loading && s == null)
|
|
Padding(
|
|
padding: EdgeInsets.only(top: 60),
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppTheme.primary,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
)
|
|
else if (s == null || s.days.isEmpty)
|
|
_EmptyState(
|
|
aiEnabled: showAiEntry,
|
|
onAction: showAiEntry
|
|
? () => context.go('/chat')
|
|
: () => context.push('/add'),
|
|
companionName: PublicConfigApi.companionName,
|
|
avatarKey: PublicConfigApi.companionAvatarKey,
|
|
)
|
|
else ...[
|
|
Padding(
|
|
padding: EdgeInsets.only(left: 2, bottom: 9),
|
|
child: Text(
|
|
'最近账单',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
...s.days.map(
|
|
(d) => _DayCard(
|
|
day: d,
|
|
onDeleted: refresh,
|
|
budgetsByCategory: budgetsByCategory,
|
|
),
|
|
),
|
|
],
|
|
SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BudgetSummaryCard extends StatelessWidget {
|
|
final BudgetsData data;
|
|
final VoidCallback onTap;
|
|
|
|
const _BudgetSummaryCard({required this.data, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final total = data.total;
|
|
final amount =
|
|
total?.amount ??
|
|
data.categories.fold<double>(0, (sum, item) => sum + item.amount);
|
|
final spent =
|
|
total?.spent ??
|
|
data.categories.fold<double>(0, (sum, item) => sum + item.spent);
|
|
final rawRatio = amount <= 0 ? 0.0 : spent / amount;
|
|
final ratio = rawRatio.clamp(0.0, 1.0);
|
|
final warningColor = rawRatio >= 1
|
|
? AppTheme.red
|
|
: rawRatio >= 0.8
|
|
? AppTheme.orange
|
|
: AppTheme.primary;
|
|
|
|
return Material(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: amount <= 0
|
|
? Row(
|
|
children: [
|
|
Icon(
|
|
Icons.donut_small_rounded,
|
|
size: 22,
|
|
color: AppTheme.primary,
|
|
),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'本月预算',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Text(
|
|
'设一个可执行的消费边界',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'去设置',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
total?.isRecurring == true ? '周期预算' : '本月预算',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Spacer(),
|
|
Text(
|
|
rawRatio >= 1
|
|
? '已超 ¥${(spent - amount).toStringAsFixed(0)}'
|
|
: '剩余 ¥${(amount - spent).toStringAsFixed(0)}',
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
color: warningColor,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 9),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(3),
|
|
child: LinearProgressIndicator(
|
|
value: ratio,
|
|
minHeight: 6,
|
|
backgroundColor: context.jz.line,
|
|
color: warningColor,
|
|
),
|
|
),
|
|
SizedBox(height: 7),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'已用 ¥${spent.toStringAsFixed(0)}',
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
Spacer(),
|
|
Text(
|
|
'预算 ¥${amount.toStringAsFixed(0)} · ${(rawRatio * 100).toStringAsFixed(0)}%',
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeroCol extends StatelessWidget {
|
|
final String label;
|
|
final double value;
|
|
final bool isCount;
|
|
const _HeroCol({
|
|
required this.label,
|
|
required this.value,
|
|
this.isCount = false,
|
|
});
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
),
|
|
),
|
|
SizedBox(height: 2),
|
|
Text(
|
|
isCount
|
|
? value.toInt().toString()
|
|
: '¥ ${value.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
final VoidCallback onAction;
|
|
final String companionName, avatarKey;
|
|
final bool aiEnabled;
|
|
|
|
const _EmptyState({
|
|
required this.onAction,
|
|
required this.aiEnabled,
|
|
required this.companionName,
|
|
required this.avatarKey,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: aiEnabled
|
|
? context.jz.aiBackground
|
|
: context.jz.primaryBackground,
|
|
),
|
|
child: AppIcons.icon(
|
|
aiEnabled ? AppIcons.avatarAsset(avatarKey) : AppIcons.wallet,
|
|
size: 48,
|
|
color: aiEnabled ? AppTheme.ai : AppTheme.primary,
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'还没有账单',
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
|
),
|
|
SizedBox(height: 6),
|
|
Text(
|
|
aiEnabled
|
|
? '跟我说句话就能记账,比如\n"早饭包子豆浆 6 块" · "打车 45"'
|
|
: '点击下方按钮手动记录第一笔账单',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
color: context.jz.text2,
|
|
height: 1.7,
|
|
),
|
|
),
|
|
SizedBox(height: 18),
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: aiEnabled ? AppTheme.ai : AppTheme.primary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
onPressed: onAction,
|
|
icon: AppIcons.icon(
|
|
aiEnabled ? AppIcons.chat : AppIcons.plus,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
label: Text(aiEnabled ? '和${companionName}聊聊' : '记一笔'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DayCard extends StatelessWidget {
|
|
final DayGroup day;
|
|
final VoidCallback onDeleted;
|
|
final Map<int, BudgetItem> budgetsByCategory;
|
|
const _DayCard({
|
|
required this.day,
|
|
required this.onDeleted,
|
|
required this.budgetsByCategory,
|
|
});
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final now = ShanghaiTime.now;
|
|
final isToday =
|
|
day.date.year == now.year &&
|
|
day.date.month == now.month &&
|
|
day.date.day == now.day;
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 9),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
isToday
|
|
? '今天 · ${day.date.month}月${day.date.day}日'
|
|
: '${day.date.month}月${day.date.day}日',
|
|
style: TextStyle(fontSize: 11, color: context.jz.text3),
|
|
),
|
|
Spacer(),
|
|
Text(
|
|
[
|
|
if (day.income > 0) '收 ¥${day.income.toStringAsFixed(2)}',
|
|
if (day.expense > 0) '支 ¥${day.expense.toStringAsFixed(2)}',
|
|
].join(' · '),
|
|
style: TextStyle(fontSize: 11, color: context.jz.text3),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 0.5, color: context.jz.line),
|
|
...day.items.map(
|
|
(t) => _TxRow(
|
|
tx: t,
|
|
onDeleted: onDeleted,
|
|
budget: budgetsByCategory[t.categoryId],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TxRow extends StatelessWidget {
|
|
final TxItem tx;
|
|
final VoidCallback onDeleted;
|
|
final BudgetItem? budget;
|
|
const _TxRow({required this.tx, required this.onDeleted, this.budget});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hh =
|
|
'${tx.occurredAt.hour.toString().padLeft(2, '0')}:${tx.occurredAt.minute.toString().padLeft(2, '0')}';
|
|
return _SwipeReveal(
|
|
id: tx.id,
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => TxDetailPage(tx: tx)),
|
|
),
|
|
onDelete: () async {
|
|
try {
|
|
await TxApi.delete(tx.id);
|
|
onDeleted();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
content: Text('已移入回收站'),
|
|
action: SnackBarAction(
|
|
label: '撤销',
|
|
textColor: AppTheme.primary,
|
|
onPressed: () async {
|
|
try {
|
|
await TxApi.restore(tx.id);
|
|
onDeleted();
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(apiErrorMessage(error))),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
|
}
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
CategoryIconBox(
|
|
iconKey: tx.categoryIcon,
|
|
colorKey: tx.categoryColor,
|
|
),
|
|
SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
tx.note ?? tx.categoryName,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
hh,
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
color: context.jz.text3,
|
|
),
|
|
),
|
|
if (!tx.isIncome &&
|
|
budget != null &&
|
|
budget!.amount > 0 &&
|
|
budget!.spent / budget!.amount >= 0.8) ...[
|
|
SizedBox(width: 5),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 1.5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: budget!.spent >= budget!.amount
|
|
? context.jz.expenseBackground
|
|
: context.jz.warningBackground,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
budget!.spent >= budget!.amount
|
|
? '预算已超'
|
|
: '预算 ${(budget!.spent / budget!.amount * 100).toStringAsFixed(0)}%',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: budget!.spent >= budget!.amount
|
|
? AppTheme.red
|
|
: AppTheme.orange,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (tx.isAi) ...[
|
|
SizedBox(width: 5),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 1.5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.aiBackground,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'✦ AI',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: AppTheme.ai,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'${tx.isIncome ? '+' : '-'}${tx.amount.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 14.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: tx.isIncome ? AppTheme.primary : context.jz.text,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SwipeReveal extends StatefulWidget {
|
|
static const double actionWidth = 76;
|
|
static final ValueNotifier<int?> _openId = ValueNotifier<int?>(null);
|
|
|
|
final int id;
|
|
final Widget child;
|
|
final VoidCallback onTap;
|
|
final Future<void> Function() onDelete;
|
|
|
|
const _SwipeReveal({
|
|
required this.id,
|
|
required this.child,
|
|
required this.onTap,
|
|
required this.onDelete,
|
|
});
|
|
|
|
static void closeOpen() {
|
|
_openId.value = null;
|
|
}
|
|
|
|
@override
|
|
State<_SwipeReveal> createState() => _SwipeRevealState();
|
|
}
|
|
|
|
class _SwipeRevealState extends State<_SwipeReveal> {
|
|
double _dragOffset = 0;
|
|
bool _dragging = false;
|
|
bool _deleting = false;
|
|
|
|
void _startDrag(DragStartDetails details) {
|
|
_dragging = true;
|
|
_dragOffset = _SwipeReveal._openId.value == widget.id
|
|
? -_SwipeReveal.actionWidth
|
|
: 0;
|
|
if (_SwipeReveal._openId.value != widget.id) {
|
|
_SwipeReveal.closeOpen();
|
|
}
|
|
}
|
|
|
|
void _updateDrag(DragUpdateDetails details) {
|
|
setState(() {
|
|
_dragOffset = (_dragOffset + details.delta.dx).clamp(
|
|
-_SwipeReveal.actionWidth,
|
|
0,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _endDrag(DragEndDetails details) {
|
|
final reveal =
|
|
_dragOffset <= -36 ||
|
|
(details.primaryVelocity != null && details.primaryVelocity! < -500);
|
|
_dragging = false;
|
|
_SwipeReveal._openId.value = reveal ? widget.id : null;
|
|
setState(() => _dragOffset = reveal ? -_SwipeReveal.actionWidth : 0);
|
|
}
|
|
|
|
Future<void> _delete() async {
|
|
if (_deleting) return;
|
|
setState(() => _deleting = true);
|
|
_SwipeReveal.closeOpen();
|
|
try {
|
|
await widget.onDelete();
|
|
} finally {
|
|
if (mounted) setState(() => _deleting = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<int?>(
|
|
valueListenable: _SwipeReveal._openId,
|
|
builder: (_, openId, __) {
|
|
final target = _dragging
|
|
? _dragOffset
|
|
: openId == widget.id
|
|
? -_SwipeReveal.actionWidth
|
|
: 0.0;
|
|
return ClipRect(
|
|
child: Stack(
|
|
alignment: Alignment.centerRight,
|
|
children: [
|
|
Positioned.fill(
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Semantics(
|
|
button: true,
|
|
label: '删除账单',
|
|
child: Material(
|
|
color: AppTheme.red,
|
|
child: InkWell(
|
|
onTap: _deleting ? null : _delete,
|
|
child: SizedBox(
|
|
width: _SwipeReveal.actionWidth,
|
|
child: Center(
|
|
child: _deleting
|
|
? SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: AppIcons.icon(
|
|
AppIcons.trash,
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
AnimatedContainer(
|
|
duration: _dragging
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
transform: Matrix4.translationValues(target, 0, 0),
|
|
color: context.jz.card,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: openId == widget.id
|
|
? _SwipeReveal.closeOpen
|
|
: widget.onTap,
|
|
onHorizontalDragStart: _startDrag,
|
|
onHorizontalDragUpdate: _updateDrag,
|
|
onHorizontalDragEnd: _endDrag,
|
|
onHorizontalDragCancel: () {
|
|
_dragging = false;
|
|
_SwipeReveal.closeOpen();
|
|
},
|
|
child: widget.child,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|