1102 lines
37 KiB
Dart
1102 lines
37 KiB
Dart
import 'dart:io';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
|
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
|
import 'package:miaoji_zhang/shared/services/screenshot_channel.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_controls.dart';
|
|
|
|
class ScreenshotParseSheet extends StatefulWidget {
|
|
final String imagePath;
|
|
|
|
const ScreenshotParseSheet({super.key, required this.imagePath});
|
|
|
|
@override
|
|
State<ScreenshotParseSheet> createState() => _ScreenshotParseSheetState();
|
|
|
|
static Future<bool?> show(BuildContext context, String imagePath) {
|
|
return showModalBottomSheet<bool>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => ScreenshotParseSheet(imagePath: imagePath),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
|
List<CategoryItem> _expenseCategories = [];
|
|
List<CategoryItem> _incomeCategories = [];
|
|
final List<_ScreenshotDraft> _drafts = [];
|
|
bool _parsing = true;
|
|
bool _saving = false;
|
|
String? _error;
|
|
Uint8List? _imageBytes;
|
|
bool _imageCleaned = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialize();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final draft in _drafts) {
|
|
draft.dispose();
|
|
}
|
|
_cleanupImage();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
try {
|
|
final groups = await Future.wait<List<CategoryItem>>([
|
|
TxApi.categories('expense'),
|
|
TxApi.categories('income'),
|
|
]);
|
|
_expenseCategories = groups[0];
|
|
_incomeCategories = groups[1];
|
|
} catch (e) {
|
|
_error = '分类加载失败:${apiErrorMessage(e)}';
|
|
}
|
|
await _parseImage();
|
|
}
|
|
|
|
Future<void> _parseImage() async {
|
|
try {
|
|
SessionStore.instance.requireOnline('截屏和图片 AI 识别需要登录并连接网络');
|
|
await ScreenshotChannel.startAiProgress();
|
|
final bytes = await File(widget.imagePath).readAsBytes();
|
|
if (mounted) setState(() => _imageBytes = bytes);
|
|
final formData = FormData.fromMap({
|
|
'file': MultipartFile.fromBytes(bytes, filename: 'screenshot.png'),
|
|
});
|
|
final response = await ApiClient.instance.dio.post(
|
|
'/api/parse/image',
|
|
data: formData,
|
|
queryParameters: const {'source': 'screenshot'},
|
|
);
|
|
if (!mounted) return;
|
|
|
|
final data = Map<String, dynamic>.from(response.data as Map);
|
|
final rawItems = data['items'] is List
|
|
? List<Object?>.from(data['items'] as List)
|
|
: <Object?>[];
|
|
if (rawItems.isEmpty && data['matched'] == true) {
|
|
rawItems.add(data);
|
|
}
|
|
|
|
for (final raw in rawItems.take(20)) {
|
|
if (raw is! Map) continue;
|
|
_drafts.add(_draftFromJson(Map<String, dynamic>.from(raw)));
|
|
}
|
|
await ScreenshotChannel.updateAiProgress(_drafts.length);
|
|
if (_drafts.isEmpty) {
|
|
_drafts.add(_emptyDraft());
|
|
_error ??= 'AI 没有确认到有效账单,请手动补充';
|
|
}
|
|
|
|
setState(() => _parsing = false);
|
|
} catch (e) {
|
|
final message = apiErrorMessage(e);
|
|
await ScreenshotChannel.failAiProgress(message);
|
|
if (!mounted) return;
|
|
_drafts.add(_emptyDraft());
|
|
setState(() {
|
|
_parsing = false;
|
|
_error = message;
|
|
});
|
|
} finally {
|
|
_cleanupImage();
|
|
}
|
|
}
|
|
|
|
void _cleanupImage() {
|
|
if (_imageCleaned) return;
|
|
_imageCleaned = true;
|
|
try {
|
|
final file = File(widget.imagePath);
|
|
if (file.existsSync()) file.deleteSync();
|
|
} catch (_) {
|
|
// Startup cleanup retries files that the OS still has open.
|
|
}
|
|
}
|
|
|
|
_ScreenshotDraft _draftFromJson(Map<String, dynamic> json) {
|
|
final type = switch (json['type']?.toString().toLowerCase()) {
|
|
'income' => 'income',
|
|
'expense' => 'expense',
|
|
'transfer' => 'transfer',
|
|
_ => 'unknown',
|
|
};
|
|
final rawTransferDirection = json['transferDirection']?.toString();
|
|
final transferDirection =
|
|
rawTransferDirection == 'in' || rawTransferDirection == 'out'
|
|
? rawTransferDirection!
|
|
: 'out';
|
|
final categoryId = (json['categoryId'] as num?)?.toInt();
|
|
final categoryName = json['categoryName']?.toString() ?? '其他';
|
|
final categoryIcon = json['categoryIcon']?.toString() ?? 'tag';
|
|
final categories = _categoriesFor(type, transferDirection);
|
|
final exists = categories.any((category) => category.id == categoryId);
|
|
if (type != 'unknown' && !exists && categoryId != null) {
|
|
categories.add(
|
|
CategoryItem.fromJson({
|
|
'id': categoryId,
|
|
'name': categoryName,
|
|
'iconKey': categoryIcon,
|
|
'type': type == 'transfer'
|
|
? transferDirection == 'in'
|
|
? 'income'
|
|
: 'expense'
|
|
: type,
|
|
'sortOrder': 999,
|
|
'isCustom': false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
final fallbackId = categories.isEmpty ? null : categories.first.id;
|
|
final amount = (json['amount'] as num?)?.toDouble() ?? 0;
|
|
final parsedOccurredAt = DateTime.tryParse(
|
|
json['occurredAt']?.toString() ?? '',
|
|
);
|
|
return _ScreenshotDraft(
|
|
type: type,
|
|
categoryId: type == 'unknown'
|
|
? null
|
|
: (exists || categoryId != null ? categoryId : fallbackId),
|
|
included: type != 'unknown',
|
|
amount: amount > 0 ? amount.toStringAsFixed(2) : '',
|
|
note: json['note']?.toString() ?? '',
|
|
paymentMethod: json['paymentMethod']?.toString() ?? '',
|
|
occurredAt: parsedOccurredAt == null
|
|
? ShanghaiTime.now
|
|
: ShanghaiTime.toCivil(parsedOccurredAt),
|
|
transferDirection: transferDirection,
|
|
counterparty: json['counterparty']?.toString() ?? '',
|
|
);
|
|
}
|
|
|
|
_ScreenshotDraft _emptyDraft() {
|
|
final categoryId = _expenseCategories.isEmpty
|
|
? null
|
|
: _expenseCategories.first.id;
|
|
return _ScreenshotDraft(
|
|
type: 'expense',
|
|
categoryId: categoryId,
|
|
amount: '',
|
|
note: '',
|
|
paymentMethod: '',
|
|
occurredAt: ShanghaiTime.now,
|
|
);
|
|
}
|
|
|
|
List<CategoryItem> _categoriesFor(String type, [String direction = 'out']) {
|
|
return switch (type) {
|
|
'income' => _incomeCategories,
|
|
'expense' => _expenseCategories,
|
|
'transfer' => direction == 'in' ? _incomeCategories : _expenseCategories,
|
|
_ => <CategoryItem>[],
|
|
};
|
|
}
|
|
|
|
void _addDraft() {
|
|
setState(() => _drafts.add(_emptyDraft()));
|
|
}
|
|
|
|
void _changeType(_ScreenshotDraft draft, String type) {
|
|
if (draft.type == type) return;
|
|
final categories = _categoriesFor(type, draft.transferDirection);
|
|
setState(() {
|
|
draft.type = type;
|
|
draft.included = true;
|
|
draft.categoryId = categories.isEmpty ? null : categories.first.id;
|
|
});
|
|
}
|
|
|
|
void _changeTransferDirection(_ScreenshotDraft draft, String direction) {
|
|
if (draft.transferDirection == direction) return;
|
|
final categories = _categoriesFor('transfer', direction);
|
|
setState(() {
|
|
draft.transferDirection = direction;
|
|
draft.categoryId = categories.isEmpty ? null : categories.first.id;
|
|
});
|
|
}
|
|
|
|
Future<void> _pickOccurredAt(_ScreenshotDraft draft) async {
|
|
FocusScope.of(context).unfocus();
|
|
final value = await showJzDateTimeSheet(
|
|
context,
|
|
initial: draft.occurredAt,
|
|
firstDate: DateTime(2000),
|
|
lastDate: ShanghaiTime.now.add(const Duration(days: 1)),
|
|
title: '选择账单时间',
|
|
);
|
|
if (value != null && mounted) {
|
|
setState(() => draft.occurredAt = value);
|
|
}
|
|
}
|
|
|
|
Future<void> _selectCategory(_ScreenshotDraft draft, int billIndex) async {
|
|
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
|
if (categories.isEmpty) {
|
|
_showMessage('当前收支类型暂无可选分类');
|
|
return;
|
|
}
|
|
FocusScope.of(context).unfocus();
|
|
final selected = await showModalBottomSheet<int>(
|
|
context: context,
|
|
useSafeArea: true,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => FractionallySizedBox(
|
|
heightFactor: 0.62,
|
|
child: Material(
|
|
color: context.jz.background,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
const _SheetHandle(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 5, 12, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'第 ${billIndex + 1} 笔 · 选择分类',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: '关闭',
|
|
onPressed: () => Navigator.pop(sheetContext),
|
|
icon: Icon(Icons.close_rounded),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: GridView.builder(
|
|
padding: const EdgeInsets.fromLTRB(16, 2, 16, 20),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 0.86,
|
|
),
|
|
itemCount: categories.length,
|
|
itemBuilder: (_, index) {
|
|
final category = categories[index];
|
|
return _CategoryChoice(
|
|
category: category,
|
|
selected: category.id == draft.categoryId,
|
|
onTap: () => Navigator.pop(sheetContext, category.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (selected != null && mounted) {
|
|
setState(() => draft.categoryId = selected);
|
|
}
|
|
}
|
|
|
|
Future<void> _confirm() async {
|
|
final selected = _drafts
|
|
.where((draft) => draft.included && !draft.saved)
|
|
.toList();
|
|
if (selected.isEmpty) {
|
|
_showMessage('请至少选择一笔尚未保存的账单');
|
|
return;
|
|
}
|
|
|
|
for (var index = 0; index < selected.length; index++) {
|
|
final draft = selected[index];
|
|
if (draft.type != 'income' &&
|
|
draft.type != 'expense' &&
|
|
draft.type != 'transfer') {
|
|
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认账单类型');
|
|
return;
|
|
}
|
|
if ((double.tryParse(draft.amountController.text) ?? 0) <= 0) {
|
|
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔金额无效');
|
|
return;
|
|
}
|
|
if (draft.categoryId == null) {
|
|
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔还没有选择分类');
|
|
return;
|
|
}
|
|
}
|
|
|
|
final selectedCount = selected.length;
|
|
final selectedTotal = selected.fold<double>(
|
|
0,
|
|
(sum, draft) => sum + (double.tryParse(draft.amountController.text) ?? 0),
|
|
);
|
|
|
|
setState(() => _saving = true);
|
|
var savedAny = false;
|
|
try {
|
|
for (final draft in selected) {
|
|
final note = draft.noteController.text.trim();
|
|
final payment = draft.paymentController.text.trim();
|
|
await TxApi.create(
|
|
categoryId: draft.categoryId!,
|
|
type: draft.type,
|
|
amount: double.parse(draft.amountController.text),
|
|
note: note.isEmpty ? null : note,
|
|
paymentMethod: payment.isEmpty ? null : payment,
|
|
source: 'screenshot',
|
|
sourceText: '截屏识别',
|
|
occurredAt: draft.occurredAt,
|
|
transferDirection: draft.type == 'transfer'
|
|
? draft.transferDirection
|
|
: null,
|
|
counterparty:
|
|
draft.type == 'transfer' &&
|
|
draft.counterpartyController.text.trim().isNotEmpty
|
|
? draft.counterpartyController.text.trim()
|
|
: null,
|
|
);
|
|
draft.saved = true;
|
|
savedAny = true;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
if (savedAny) TransactionEvents.notifyChanged();
|
|
await ScreenshotChannel.finishAiProgress(selectedCount, selectedTotal);
|
|
if (!mounted) return;
|
|
Navigator.pop(context, true);
|
|
} catch (e) {
|
|
if (savedAny) TransactionEvents.notifyChanged();
|
|
final next = _drafts.indexWhere(
|
|
(draft) => draft.included && !draft.saved,
|
|
);
|
|
final prefix = next >= 0 ? '第 ${next + 1} 笔保存失败:' : '保存失败:';
|
|
final message = '$prefix${apiErrorMessage(e)}';
|
|
await ScreenshotChannel.failAiProgress(message);
|
|
if (!mounted) return;
|
|
_showMessage(message);
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
void _showMessage(String message) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
|
|
int get _selectedCount {
|
|
return _drafts.where((draft) => draft.included && !draft.saved).length;
|
|
}
|
|
|
|
double get _selectedTotal {
|
|
return _drafts.where((draft) => draft.included && !draft.saved).fold(0, (
|
|
sum,
|
|
draft,
|
|
) {
|
|
return sum + (double.tryParse(draft.amountController.text) ?? 0);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FractionallySizedBox(
|
|
heightFactor: 0.94,
|
|
child: Material(
|
|
color: context.jz.background,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
if (_parsing)
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
color: AppTheme.ai,
|
|
strokeWidth: 2.5,
|
|
),
|
|
SizedBox(height: 14),
|
|
Text(
|
|
'AI 正在逐笔分析截图...',
|
|
style: TextStyle(color: context.jz.text2, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else ...[
|
|
if (_error != null) _buildError(),
|
|
Expanded(
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
itemCount: _drafts.length + 1,
|
|
separatorBuilder: (_, _) => SizedBox(height: 10),
|
|
itemBuilder: (context, index) {
|
|
if (index == _drafts.length) {
|
|
return OutlinedButton.icon(
|
|
onPressed: _saving ? null : _addDraft,
|
|
icon: Icon(Icons.add, size: 18),
|
|
label: Text('手动补一笔'),
|
|
);
|
|
}
|
|
return _buildDraftCard(_drafts[index], index);
|
|
},
|
|
),
|
|
),
|
|
_buildFooter(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 14),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 5,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.line,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
SizedBox(height: 11),
|
|
Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(11),
|
|
child: _imageBytes == null
|
|
? Container(
|
|
width: 54,
|
|
height: 76,
|
|
color: context.jz.background,
|
|
child: Icon(Icons.broken_image_outlined),
|
|
)
|
|
: Image.memory(
|
|
_imageBytes!,
|
|
width: 54,
|
|
height: 76,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'截屏记账',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
SizedBox(height: 5),
|
|
Text(
|
|
_parsing
|
|
? '正在识别交易明细'
|
|
: '识别到 ${_drafts.length} 笔,可逐笔修改或取消',
|
|
style: TextStyle(color: context.jz.text2, fontSize: 12),
|
|
),
|
|
if (!_parsing) ...[
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'已选 $_selectedCount 笔 · 合计 ¥${_selectedTotal.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
color: AppTheme.ai,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
|
icon: Icon(Icons.close),
|
|
tooltip: '关闭',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildError() {
|
|
return Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.fromLTRB(14, 10, 14, 0),
|
|
padding: const EdgeInsets.all(11),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.expenseBackground,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(color: AppTheme.red, fontSize: 11, height: 1.4),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDraftCard(_ScreenshotDraft draft, int index) {
|
|
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
|
final selectedCategory = categories
|
|
.where((category) => category.id == draft.categoryId)
|
|
.firstOrNull;
|
|
final disabled =
|
|
draft.saved || (!draft.included && draft.type != 'unknown');
|
|
|
|
return AnimatedOpacity(
|
|
opacity: disabled ? 0.62 : 1,
|
|
duration: const Duration(milliseconds: 180),
|
|
child: Card(
|
|
margin: EdgeInsets.zero,
|
|
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 11, 12, 13),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Checkbox(
|
|
value: draft.included,
|
|
onChanged: draft.saved || _saving
|
|
? null
|
|
: (value) {
|
|
setState(() => draft.included = value ?? false);
|
|
},
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'第 ${index + 1} 笔',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
if (draft.saved)
|
|
const _DraftStateChip(label: '已入账', color: AppTheme.primary)
|
|
else
|
|
_DraftStateChip(
|
|
label: switch (draft.type) {
|
|
'income' => '收入',
|
|
'expense' => '支出',
|
|
'transfer' =>
|
|
draft.transferDirection == 'in' ? '转入' : '转出',
|
|
_ => '待确认',
|
|
},
|
|
color: draft.type == 'unknown'
|
|
? context.jz.text3
|
|
: (draft.type == 'income'
|
|
? AppTheme.primary
|
|
: AppTheme.red),
|
|
),
|
|
],
|
|
),
|
|
IgnorePointer(
|
|
ignoring: disabled || _saving,
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: draft.amountController,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(
|
|
RegExp(r'^\d{0,9}(\.\d{0,2})?'),
|
|
),
|
|
],
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
labelText: '金额',
|
|
prefixText: '¥ ',
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TypeButton(
|
|
label: '支出',
|
|
selected: draft.type == 'expense',
|
|
color: AppTheme.red,
|
|
onTap: () => _changeType(draft, 'expense'),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: _TypeButton(
|
|
label: '转账',
|
|
selected: draft.type == 'transfer',
|
|
color: AppTheme.orange,
|
|
onTap: () => _changeType(draft, 'transfer'),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: _TypeButton(
|
|
label: '收入',
|
|
selected: draft.type == 'income',
|
|
color: AppTheme.primary,
|
|
onTap: () => _changeType(draft, 'income'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (draft.type == 'transfer') ...[
|
|
SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TypeButton(
|
|
label: '转出',
|
|
selected: draft.transferDirection == 'out',
|
|
color: AppTheme.orange,
|
|
onTap: () =>
|
|
_changeTransferDirection(draft, 'out'),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: _TypeButton(
|
|
label: '转入',
|
|
selected: draft.transferDirection == 'in',
|
|
color: AppTheme.primary,
|
|
onTap: () =>
|
|
_changeTransferDirection(draft, 'in'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10),
|
|
TextField(
|
|
controller: draft.counterpartyController,
|
|
decoration: InputDecoration(labelText: '转账对方'),
|
|
),
|
|
],
|
|
SizedBox(height: 10),
|
|
_CategoryField(
|
|
category: selectedCategory,
|
|
enabled: categories.isNotEmpty,
|
|
onTap: () => _selectCategory(draft, index),
|
|
),
|
|
SizedBox(height: 10),
|
|
_DateTimeField(
|
|
value: draft.occurredAt,
|
|
onTap: () => _pickOccurredAt(draft),
|
|
),
|
|
SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: draft.noteController,
|
|
decoration: InputDecoration(
|
|
labelText: '备注',
|
|
hintText: '商户或商品',
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: draft.paymentController,
|
|
decoration: InputDecoration(
|
|
labelText: '支付方式',
|
|
hintText: '微信/支付宝',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFooter() {
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
16,
|
|
11,
|
|
16,
|
|
MediaQuery.paddingOf(context).bottom + 11,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
border: Border(top: BorderSide(color: context.jz.line)),
|
|
),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: _saving || _selectedCount == 0 ? null : _confirm,
|
|
child: _saving
|
|
? SizedBox(
|
|
width: 19,
|
|
height: 19,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text('确认入账($_selectedCount 笔)'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScreenshotDraft {
|
|
String type;
|
|
String transferDirection;
|
|
int? categoryId;
|
|
bool included;
|
|
bool saved = false;
|
|
DateTime occurredAt;
|
|
final TextEditingController amountController;
|
|
final TextEditingController noteController;
|
|
final TextEditingController paymentController;
|
|
final TextEditingController counterpartyController;
|
|
|
|
_ScreenshotDraft({
|
|
required this.type,
|
|
required this.categoryId,
|
|
bool included = true,
|
|
required String amount,
|
|
required String note,
|
|
required String paymentMethod,
|
|
required this.occurredAt,
|
|
this.transferDirection = 'out',
|
|
String counterparty = '',
|
|
}) : included = included,
|
|
amountController = TextEditingController(text: amount),
|
|
noteController = TextEditingController(text: note),
|
|
paymentController = TextEditingController(text: paymentMethod),
|
|
counterpartyController = TextEditingController(text: counterparty);
|
|
|
|
void dispose() {
|
|
amountController.dispose();
|
|
noteController.dispose();
|
|
paymentController.dispose();
|
|
counterpartyController.dispose();
|
|
}
|
|
}
|
|
|
|
class _DraftStateChip extends StatelessWidget {
|
|
final String label;
|
|
final Color color;
|
|
|
|
const _DraftStateChip({required this.label, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TypeButton extends StatelessWidget {
|
|
final String label;
|
|
final bool selected;
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
|
|
const _TypeButton({
|
|
required this.label,
|
|
required this.selected,
|
|
required this.color,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: selected ? color.withValues(alpha: 0.1) : context.jz.background,
|
|
borderRadius: BorderRadius.circular(9),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(9),
|
|
child: Container(
|
|
height: 38,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: selected ? color : context.jz.line),
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: selected ? color : context.jz.text2,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SheetHandle extends StatelessWidget {
|
|
const _SheetHandle();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 10, bottom: 8),
|
|
child: Container(
|
|
width: 38,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.line,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoryField extends StatelessWidget {
|
|
final CategoryItem? category;
|
|
final bool enabled;
|
|
final VoidCallback onTap;
|
|
|
|
const _CategoryField({
|
|
required this.category,
|
|
required this.enabled,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: enabled ? onTap : null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
height: 54,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: context.jz.line),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (category != null)
|
|
CategoryIconBox(
|
|
iconKey: category!.iconKey,
|
|
colorKey: category!.colorKey,
|
|
size: 30,
|
|
)
|
|
else
|
|
Icon(Icons.category_outlined, color: context.jz.text3),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'分类',
|
|
style: TextStyle(fontSize: 10, color: context.jz.text3),
|
|
),
|
|
Text(
|
|
category?.name ?? '请选择分类',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.keyboard_arrow_right_rounded, color: context.jz.text3),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DateTimeField extends StatelessWidget {
|
|
final DateTime value;
|
|
final VoidCallback onTap;
|
|
|
|
const _DateTimeField({required this.value, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final text =
|
|
'${value.year}-${value.month.toString().padLeft(2, '0')}-'
|
|
'${value.day.toString().padLeft(2, '0')} '
|
|
'${value.hour.toString().padLeft(2, '0')}:'
|
|
'${value.minute.toString().padLeft(2, '0')}';
|
|
return Material(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
height: 50,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: context.jz.line),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.schedule_rounded, size: 20, color: AppTheme.ai),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
'账单时间',
|
|
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
|
),
|
|
Spacer(),
|
|
Text(
|
|
text,
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
|
),
|
|
SizedBox(width: 3),
|
|
Icon(
|
|
Icons.keyboard_arrow_right_rounded,
|
|
size: 20,
|
|
color: context.jz.text3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoryChoice extends StatelessWidget {
|
|
final CategoryItem category;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _CategoryChoice({
|
|
required this.category,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: selected ? context.jz.aiBackground : context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 9),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: selected ? AppTheme.ai : context.jz.line,
|
|
width: selected ? 1.4 : 0.8,
|
|
),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
CategoryIconBox(
|
|
iconKey: category.iconKey,
|
|
colorKey: category.colorKey,
|
|
size: 38,
|
|
),
|
|
if (selected)
|
|
Positioned(
|
|
right: -4,
|
|
top: -4,
|
|
child: CircleAvatar(
|
|
radius: 8,
|
|
backgroundColor: AppTheme.ai,
|
|
child: Icon(Icons.check, size: 11, color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 7),
|
|
Text(
|
|
category.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: selected ? AppTheme.ai : context.jz.text,
|
|
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|