Files
jizhi/frontend/lib/features/home/pages/search_page.dart
T

278 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
/// 搜索页(P19
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _input = TextEditingController();
List<TxItem> _results = [];
bool _aiOnly = false;
bool _searched = false;
bool _loading = false;
String? _error;
String? _timeFilter; // today | week | month
String? _typeFilter;
double? _minAmount, _maxAmount;
Future<void> _search() async {
final q = _input.text.trim();
if (q.isEmpty &&
!_aiOnly &&
_timeFilter == null &&
_typeFilter == null &&
_minAmount == null &&
_maxAmount == null)
return;
setState(() {
_loading = true;
_error = null;
});
try {
final now = ShanghaiTime.now;
DateTime? from;
DateTime? to;
if (_timeFilter == 'today') {
from = DateTime(now.year, now.month, now.day);
to = from.add(const Duration(days: 1));
} else if (_timeFilter == 'week') {
final today = DateTime(now.year, now.month, now.day);
from = today.subtract(Duration(days: today.weekday - 1));
to = from.add(const Duration(days: 7));
} else if (_timeFilter == 'month') {
from = DateTime(now.year, now.month);
to = DateTime(now.year, now.month + 1);
}
final list = await SearchApi.search(
q: q,
aiOnly: _aiOnly,
minAmount: _minAmount,
maxAmount: _maxAmount,
from: from,
to: to,
type: _typeFilter,
);
if (mounted)
setState(() {
_results = list;
_searched = true;
});
} catch (error) {
if (mounted) setState(() => _error = apiErrorMessage(error));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final total = _results
.where((t) => t.isExpense)
.fold<double>(0, (s, t) => s + t.amount);
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _input,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _search(),
decoration: InputDecoration(
hintText: '搜备注 / 对方 / 分类 / 原话',
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
),
actions: [TextButton(onPressed: _search, child: Text('搜索'))],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
FilterChip(
label: Text('仅 AI 记账', style: TextStyle(fontSize: 11)),
selected: _aiOnly,
selectedColor: context.jz.aiBackground,
checkmarkColor: AppTheme.ai,
onSelected: (v) {
setState(() => _aiOnly = v);
_search();
},
),
...['today', 'week', 'month'].map(
(t) => FilterChip(
label: Text(
{'today': '今天', 'week': '本周', 'month': '本月'}[t]!,
style: TextStyle(fontSize: 11),
),
selected: _timeFilter == t,
selectedColor: context.jz.primaryBackground,
onSelected: (v) {
setState(() => _timeFilter = v ? t : null);
_search();
},
),
),
for (final option in const [
('expense', '支出'),
('transfer', '转账'),
('income', '收入'),
])
FilterChip(
label: Text(option.$2, style: TextStyle(fontSize: 11)),
selected: _typeFilter == option.$1,
selectedColor: context.jz.primaryBackground,
onSelected: (selected) {
setState(() => _typeFilter = selected ? option.$1 : null);
_search();
},
),
if (_searched && !_loading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${_results.length} 笔 · ¥${total.toStringAsFixed(2)}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
),
],
),
),
Expanded(
child: _loading
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: _error != null
? AsyncErrorView(message: _error!, onRetry: _search)
: !_searched
? Center(
child: Text(
'输入关键词搜账单\n也能搜到你和 AI 说过的原话',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: context.jz.text3,
height: 1.8,
),
),
)
: _results.isEmpty
? Center(
child: Text(
'没有找到相关账单',
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _results.length,
itemBuilder: (ctx, i) {
final t = _results[i];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 10,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.jz.line,
width: 0.5,
),
),
child: Row(
children: [
CategoryIconBox(
iconKey: t.categoryIcon,
colorKey: t.categoryColor,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.note ?? t.categoryName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2),
Row(
children: [
Text(
'${t.occurredAt.month}${t.occurredAt.day}日',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
if (t.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(
'${t.isIncome ? '+' : '-'}${t.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: t.isIncome
? AppTheme.primary
: context.jz.text,
),
),
],
),
);
},
),
),
],
),
);
}
}