1348 lines
44 KiB
Dart
1348 lines
44 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:miaoji_zhang/features/add/screenshot_parse_sheet.dart';
|
|
import 'package:miaoji_zhang/features/ai_mode/pages/ai_welcome_page.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/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/app_icons.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/ai_access_gate.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
|
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
|
|
const stickerStyle = <String, (String, Color)>{
|
|
'salary': (AppIcons.money, AppTheme.primary),
|
|
'forgive': (AppIcons.smile, AppTheme.orange),
|
|
'fire': (AppIcons.fire, AppTheme.red),
|
|
'empty_wallet': (AppIcons.wallet, AppTheme.text3),
|
|
'angry': (AppIcons.close, AppTheme.red),
|
|
'happy': (AppIcons.check, AppTheme.primary),
|
|
'treat': (AppIcons.gift, AppTheme.orange),
|
|
'shopping_joy': (AppIcons.cart, AppTheme.primaryDeep),
|
|
};
|
|
|
|
const stickerLabels = <String, String>{
|
|
'salary': '发工资啦',
|
|
'forgive': '求原谅',
|
|
'fire': '剁手警告',
|
|
'empty_wallet': '钱包空空',
|
|
'angry': '生气',
|
|
'happy': '开心',
|
|
'treat': '犒劳自己',
|
|
'shopping_joy': '购物狂喜',
|
|
};
|
|
|
|
enum _ComposerPanel { none, stickers, voice }
|
|
|
|
class ChatPageController {
|
|
Future<void> Function()? _clearContext;
|
|
|
|
Future<void> clearContext() => _clearContext?.call() ?? Future.value();
|
|
}
|
|
|
|
class ChatPage extends StatefulWidget {
|
|
final bool aiMode;
|
|
final ChatPageController? controller;
|
|
const ChatPage({super.key, this.aiMode = false, this.controller});
|
|
|
|
@override
|
|
State<ChatPage> createState() => _ChatPageState();
|
|
}
|
|
|
|
class _ChatPageState extends State<ChatPage> {
|
|
final _msgs = <ChatMsg>[];
|
|
final _ctrl = TextEditingController();
|
|
final _sc = ScrollController();
|
|
final _picker = ImagePicker();
|
|
|
|
bool _sending = false;
|
|
bool _voiceHolding = false;
|
|
bool _voiceProcessing = false;
|
|
bool _voiceCancel = false;
|
|
bool _stickersLoading = false;
|
|
_ComposerPanel _composerPanel = _ComposerPanel.none;
|
|
bool get _voiceMode => _composerPanel == _ComposerPanel.voice;
|
|
bool get _stkOpen => _composerPanel == _ComposerPanel.stickers;
|
|
bool _loaded = false;
|
|
bool _userNearBottom = true;
|
|
String? _loadError;
|
|
String _voiceText = '';
|
|
double _voiceRms = 0;
|
|
int _speechSession = 0;
|
|
OverlayEntry? _voiceOverlay;
|
|
final _inputFocus = FocusNode();
|
|
List<StickerItem> _stk = [];
|
|
|
|
static const _q = ['刚买了杯奶茶 18 块', '打车花了 45', '这个月花最多的是什么'];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.controller?._clearContext = clearContext;
|
|
_ctrl.addListener(_onInputChanged);
|
|
_sc.addListener(_trackScrollPosition);
|
|
_inputFocus.addListener(_onInputFocusChanged);
|
|
_hist();
|
|
_stkLoad();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (widget.controller?._clearContext == clearContext) {
|
|
widget.controller?._clearContext = null;
|
|
}
|
|
_speechSession++;
|
|
_voiceOverlay?.remove();
|
|
_ctrl.removeListener(_onInputChanged);
|
|
_inputFocus.removeListener(_onInputFocusChanged);
|
|
_ctrl.dispose();
|
|
_inputFocus.dispose();
|
|
_sc.removeListener(_trackScrollPosition);
|
|
_sc.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ChatPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.controller?._clearContext == clearContext) {
|
|
oldWidget.controller?._clearContext = null;
|
|
}
|
|
widget.controller?._clearContext = clearContext;
|
|
}
|
|
|
|
void _trackScrollPosition() {
|
|
if (!_sc.hasClients) return;
|
|
_userNearBottom = _sc.position.maxScrollExtent - _sc.position.pixels <= 80;
|
|
}
|
|
|
|
void _onInputChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _onInputFocusChanged() {
|
|
if (_inputFocus.hasFocus && _stkOpen && mounted) {
|
|
setState(() => _composerPanel = _ComposerPanel.none);
|
|
}
|
|
}
|
|
|
|
void _closeComposerPanel() {
|
|
if (mounted) setState(() => _composerPanel = _ComposerPanel.none);
|
|
}
|
|
|
|
Future<void> clearContext() async {
|
|
if (_sending || _voiceHolding || _voiceProcessing) return;
|
|
_inputFocus.unfocus();
|
|
_closeComposerPanel();
|
|
final confirmed = await showModalBottomSheet<bool>(
|
|
context: context,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => Material(
|
|
color: context.jz.card,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 18),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.line,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
SizedBox(height: 22),
|
|
Container(
|
|
width: 52,
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.aiBackground,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.restart_alt_rounded,
|
|
color: AppTheme.ai,
|
|
size: 27,
|
|
),
|
|
),
|
|
SizedBox(height: 13),
|
|
Text(
|
|
'开始新会话?',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
|
),
|
|
SizedBox(height: 7),
|
|
Text(
|
|
'AI 将不再使用当前聊天作为上下文,历史账单和聊天记录都不会被删除。',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
height: 1.5,
|
|
color: context.jz.text2,
|
|
),
|
|
),
|
|
SizedBox(height: 22),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Material(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(13),
|
|
child: InkWell(
|
|
onTap: () => Navigator.pop(sheetContext, false),
|
|
borderRadius: BorderRadius.circular(13),
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: Center(
|
|
child: Text(
|
|
'继续当前会话',
|
|
style: TextStyle(fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: Material(
|
|
color: AppTheme.ai,
|
|
borderRadius: BorderRadius.circular(13),
|
|
child: InkWell(
|
|
onTap: () => Navigator.pop(sheetContext, true),
|
|
borderRadius: BorderRadius.circular(13),
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: Center(
|
|
child: Text(
|
|
'开始新会话',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
try {
|
|
await ChatApi.clearContext();
|
|
if (!mounted) return;
|
|
setState(() => _msgs.clear());
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('已清理上下文,开始新会话')));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
|
}
|
|
}
|
|
|
|
Future<void> _hist() async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loaded = false;
|
|
_loadError = null;
|
|
});
|
|
}
|
|
try {
|
|
final l = await ChatApi.history();
|
|
if (mounted) {
|
|
setState(() {
|
|
_msgs
|
|
..clear()
|
|
..addAll(l);
|
|
_loaded = true;
|
|
});
|
|
_scroll(initial: true);
|
|
}
|
|
} catch (error) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loaded = true;
|
|
_loadError = apiErrorMessage(error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _stkLoad() async {
|
|
if (_stickersLoading) return;
|
|
_stickersLoading = true;
|
|
try {
|
|
final l = await StickerApi.list();
|
|
if (mounted) setState(() => _stk = l);
|
|
} finally {
|
|
_stickersLoading = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _send([String? preset, String type = 'text']) async {
|
|
final text = (preset ?? _ctrl.text).trim();
|
|
if (text.isEmpty || _sending) return;
|
|
|
|
final now = ShanghaiTime.now;
|
|
final userId = -now.millisecondsSinceEpoch;
|
|
final userMsg = ChatMsg.fromJson({
|
|
'id': userId,
|
|
'role': 'user',
|
|
'type': type,
|
|
'content': text,
|
|
'transaction': null,
|
|
'createdAt': now.toIso8601String(),
|
|
});
|
|
|
|
final assistantId = userId - 1;
|
|
final pendingAssistant = ChatMsg.fromJson({
|
|
'id': assistantId,
|
|
'role': 'assistant',
|
|
'type': 'text',
|
|
'content': '',
|
|
'transaction': null,
|
|
'createdAt': now.toIso8601String(),
|
|
});
|
|
|
|
setState(() {
|
|
_ctrl.clear();
|
|
_sending = true;
|
|
_composerPanel = _ComposerPanel.none;
|
|
_msgs.add(userMsg);
|
|
if (type == 'text') _msgs.add(pendingAssistant);
|
|
});
|
|
_scroll();
|
|
|
|
if (type == 'text') {
|
|
await ChatApi.sendStream(
|
|
text,
|
|
onToken: (fullText) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
final idx = _msgs.indexWhere((m) => m.id == assistantId);
|
|
if (idx >= 0) {
|
|
_msgs[idx] = ChatMsg.fromJson({
|
|
'id': assistantId,
|
|
'role': 'assistant',
|
|
'type': 'text',
|
|
'content': fullText,
|
|
'transaction': null,
|
|
'createdAt': ShanghaiTime.now.toIso8601String(),
|
|
});
|
|
}
|
|
});
|
|
_scroll(streaming: true);
|
|
},
|
|
onDone: (msgs) {
|
|
if (!mounted) return;
|
|
if (msgs.any((message) => message.type == 'bill_card')) {
|
|
TransactionEvents.notifyChanged();
|
|
}
|
|
setState(() {
|
|
_msgs.removeWhere((m) => m.id == assistantId);
|
|
_msgs.addAll(msgs);
|
|
_sending = false;
|
|
});
|
|
_scroll();
|
|
},
|
|
onError: (err) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_msgs.removeWhere((m) => m.id == assistantId);
|
|
_sending = false;
|
|
});
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('发送失败:$err')));
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final r = await ChatApi.send(text, type: type);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_msgs.removeWhere((m) => m.id == userId);
|
|
_msgs.addAll(r);
|
|
_sending = false;
|
|
});
|
|
if (r.any((message) => message.type == 'bill_card')) {
|
|
TransactionEvents.notifyChanged();
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() => _sending = false);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('发送失败')));
|
|
}
|
|
}
|
|
_scroll();
|
|
}
|
|
|
|
Future<void> _undo(TxItem tx) async {
|
|
try {
|
|
await TxApi.delete(tx.id);
|
|
TransactionEvents.notifyChanged();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('已撤销')));
|
|
setState(() {});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _toggleVoiceMode() {
|
|
if (SessionStore.instance.shouldUseLocalOnly) {
|
|
_showVoiceError('聊天语音输入需要登录并连接网络');
|
|
return;
|
|
}
|
|
if (!PublicConfigApi.voiceEnabled) return;
|
|
if (_voiceHolding || _voiceProcessing) return;
|
|
final openingVoice = !_voiceMode;
|
|
setState(
|
|
() => _composerPanel = openingVoice
|
|
? _ComposerPanel.voice
|
|
: _ComposerPanel.none,
|
|
);
|
|
if (openingVoice) {
|
|
_inputFocus.unfocus();
|
|
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
|
|
} else {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _inputFocus.requestFocus();
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _startVoiceHold() async {
|
|
if (!_voiceMode || _voiceHolding || _voiceProcessing) return;
|
|
final session = ++_speechSession;
|
|
setState(() {
|
|
_voiceHolding = true;
|
|
_voiceProcessing = false;
|
|
_voiceCancel = false;
|
|
_voiceText = '';
|
|
_voiceRms = 0;
|
|
});
|
|
_showVoiceOverlay();
|
|
try {
|
|
await ScreenshotChannel.startSpeechRecognition(_handleSpeechEvent);
|
|
} catch (e) {
|
|
if (!mounted || session != _speechSession) return;
|
|
_finishVoiceUi();
|
|
_showVoiceError(e.toString());
|
|
}
|
|
}
|
|
|
|
void _handleSpeechEvent(SpeechEvent event) {
|
|
if (!mounted) return;
|
|
if (event.text != null && event.text!.trim().isNotEmpty) {
|
|
_voiceText = event.text!.trim();
|
|
}
|
|
_voiceRms = event.rms;
|
|
if (event.type == 'processing') {
|
|
_voiceHolding = false;
|
|
_voiceProcessing = true;
|
|
} else if (event.type == 'final') {
|
|
final recognized = _voiceText;
|
|
_finishVoiceUi();
|
|
if (recognized.isNotEmpty) {
|
|
_ctrl
|
|
..text = recognized
|
|
..selection = TextSelection.collapsed(offset: recognized.length);
|
|
setState(() => _composerPanel = _ComposerPanel.none);
|
|
_inputFocus.requestFocus();
|
|
} else {
|
|
_showVoiceError('未识别到语音,请重试');
|
|
}
|
|
return;
|
|
} else if (event.type == 'error') {
|
|
final message = event.text?.trim();
|
|
_finishVoiceUi();
|
|
_showVoiceError(
|
|
message == null || message.isEmpty ? '语音识别失败,请重试' : message,
|
|
);
|
|
return;
|
|
} else if (event.type == 'cancelled') {
|
|
_finishVoiceUi();
|
|
return;
|
|
}
|
|
if (mounted) setState(() {});
|
|
_voiceOverlay?.markNeedsBuild();
|
|
}
|
|
|
|
void _updateVoiceCancel(LongPressMoveUpdateDetails details) {
|
|
if (!_voiceHolding) return;
|
|
final shouldCancel = details.offsetFromOrigin.dy < -70;
|
|
if (_voiceCancel == shouldCancel) return;
|
|
setState(() => _voiceCancel = shouldCancel);
|
|
_voiceOverlay?.markNeedsBuild();
|
|
}
|
|
|
|
Future<void> _endVoiceHold() async {
|
|
if (!_voiceHolding) return;
|
|
final session = _speechSession;
|
|
if (_voiceCancel) {
|
|
++_speechSession;
|
|
await ScreenshotChannel.cancelSpeechRecognition();
|
|
_finishVoiceUi();
|
|
return;
|
|
}
|
|
setState(() {
|
|
_voiceHolding = false;
|
|
_voiceProcessing = true;
|
|
});
|
|
_voiceOverlay?.markNeedsBuild();
|
|
await ScreenshotChannel.stopSpeechRecognition();
|
|
Future<void>.delayed(const Duration(seconds: 8), () async {
|
|
if (!mounted || session != _speechSession || !_voiceProcessing) return;
|
|
++_speechSession;
|
|
await ScreenshotChannel.cancelSpeechRecognition();
|
|
_finishVoiceUi();
|
|
_showVoiceError('语音识别超时,请重试');
|
|
});
|
|
}
|
|
|
|
void _showVoiceOverlay() {
|
|
_voiceOverlay?.remove();
|
|
_voiceOverlay = OverlayEntry(
|
|
builder: (context) {
|
|
final cancelling = _voiceCancel;
|
|
final processing = _voiceProcessing;
|
|
final amplitude = (_voiceRms.abs() / 12).clamp(0.0, 1.0);
|
|
return Positioned.fill(
|
|
child: Material(
|
|
color: const Color(0xD9181A1F),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Spacer(),
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 100),
|
|
width: 92 + amplitude * 24,
|
|
height: 92 + amplitude * 24,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: cancelling
|
|
? AppTheme.red
|
|
: processing
|
|
? AppTheme.ai
|
|
: AppTheme.primary,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: (cancelling ? AppTheme.red : AppTheme.primary)
|
|
.withValues(alpha: 0.35),
|
|
blurRadius: 30,
|
|
spreadRadius: 8 + amplitude * 10,
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
cancelling
|
|
? Icons.close_rounded
|
|
: processing
|
|
? Icons.more_horiz_rounded
|
|
: Icons.mic_rounded,
|
|
size: 42,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(height: 30),
|
|
Text(
|
|
cancelling
|
|
? '松开取消'
|
|
: processing
|
|
? '正在识别…'
|
|
: _voiceText.isEmpty
|
|
? '正在听,松开发送到输入框'
|
|
: _voiceText,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
SizedBox(height: 10),
|
|
if (!processing)
|
|
Text(
|
|
cancelling ? '移回按钮区域可继续' : '上滑可取消',
|
|
style: TextStyle(color: context.jz.text3, fontSize: 13),
|
|
),
|
|
Spacer(),
|
|
SizedBox(height: 96),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
Overlay.of(context).insert(_voiceOverlay!);
|
|
}
|
|
|
|
void _finishVoiceUi() {
|
|
_speechSession++;
|
|
_voiceOverlay?.remove();
|
|
_voiceOverlay = null;
|
|
if (mounted) {
|
|
setState(() {
|
|
_voiceHolding = false;
|
|
_voiceProcessing = false;
|
|
_voiceCancel = false;
|
|
_voiceRms = 0;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _showVoiceError(String raw) {
|
|
if (!mounted) return;
|
|
final message = raw
|
|
.replaceFirst('Exception: ', '')
|
|
.replaceFirst('PlatformException(', '')
|
|
.trim();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message.isEmpty ? '语音识别不可用' : message)),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickAndParseImage(ImageSource source) async {
|
|
if (SessionStore.instance.shouldUseLocalOnly) {
|
|
_showVoiceError('图片 AI 识别需要登录并连接网络');
|
|
return;
|
|
}
|
|
if (!PublicConfigApi.imageEnabled) {
|
|
_showVoiceError('图片识别功能当前已关闭');
|
|
return;
|
|
}
|
|
try {
|
|
final file = await _picker.pickImage(
|
|
source: source,
|
|
imageQuality: 90,
|
|
maxWidth: 1800,
|
|
);
|
|
if (file == null || !mounted) return;
|
|
await ScreenshotParseSheet.show(context, file.path);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
|
}
|
|
}
|
|
|
|
void _scroll({bool initial = false, bool streaming = false}) {
|
|
if (!initial && !_userNearBottom) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_sc.hasClients) return;
|
|
final target = _sc.position.maxScrollExtent;
|
|
if (initial || streaming) {
|
|
_sc.jumpTo(target);
|
|
} else {
|
|
_sc.animateTo(
|
|
target,
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _openAttachment() async {
|
|
if (SessionStore.instance.shouldUseLocalOnly) {
|
|
_showVoiceError('图片 AI 识别需要登录并连接网络');
|
|
return;
|
|
}
|
|
if (!PublicConfigApi.imageEnabled) {
|
|
_showVoiceError('图片识别功能当前已关闭');
|
|
return;
|
|
}
|
|
|
|
final source = await showJzOptionSheet<ImageSource>(
|
|
context,
|
|
title: '添加聊天附件',
|
|
subtitle: '拍摄账单或从相册选择图片,确认后进入识别页面。',
|
|
options: const [
|
|
JzOption(
|
|
value: ImageSource.camera,
|
|
label: '拍照',
|
|
subtitle: '打开相机拍摄账单或支付凭证',
|
|
leading: Icon(Icons.camera_alt_rounded, color: AppTheme.ai),
|
|
),
|
|
JzOption(
|
|
value: ImageSource.gallery,
|
|
label: '从相册选择',
|
|
subtitle: '导入已有截图或账单图片',
|
|
leading: Icon(Icons.photo_library_outlined, color: AppTheme.ai),
|
|
),
|
|
],
|
|
);
|
|
if (source != null && mounted) {
|
|
await _pickAndParseImage(source);
|
|
}
|
|
}
|
|
|
|
List<Widget> _buildStickerGrid() {
|
|
return _stk.map((s) {
|
|
final (asset, col) =
|
|
stickerStyle[s.key] ?? (AppIcons.tag, context.jz.text3);
|
|
return GestureDetector(
|
|
onTap: () => _send(s.key, 'sticker'),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Center(child: AppIcons.icon(asset, size: 28, color: col)),
|
|
),
|
|
SizedBox(height: 3),
|
|
SizedBox(
|
|
height: 18,
|
|
child: Center(
|
|
child: Text(
|
|
stickerLabels[s.key] ?? s.label.trim(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Widget _inputBar(BuildContext context) {
|
|
final sendEnabled = _ctrl.text.trim().isNotEmpty && !_sending;
|
|
final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
|
|
return Container(
|
|
color: context.jz.card,
|
|
padding: EdgeInsets.only(
|
|
left: 10,
|
|
right: 10,
|
|
top: 7,
|
|
bottom: 7 + MediaQuery.of(context).padding.bottom,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (PublicConfigApi.voiceEnabled) ...[
|
|
SizedBox(
|
|
width: 38,
|
|
height: 42,
|
|
child: IconButton(
|
|
tooltip: _voiceMode ? '切换键盘' : '切换语音',
|
|
onPressed: _toggleVoiceMode,
|
|
icon: _voiceMode
|
|
? Icon(
|
|
Icons.keyboard_alt_outlined,
|
|
size: 23,
|
|
color: context.jz.text2,
|
|
)
|
|
: AppIcons.icon(
|
|
AppIcons.mic,
|
|
size: 22,
|
|
color: context.jz.text2,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
SizedBox(width: 5),
|
|
],
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
transitionBuilder: (child, animation) => FadeTransition(
|
|
opacity: animation,
|
|
child: ScaleTransition(
|
|
scale: Tween<double>(begin: 0.97, end: 1).animate(animation),
|
|
child: child,
|
|
),
|
|
),
|
|
child: _voiceMode
|
|
? GestureDetector(
|
|
key: const ValueKey('voice-input'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onLongPressStart: (_) => _startVoiceHold(),
|
|
onLongPressMoveUpdate: _updateVoiceCancel,
|
|
onLongPressEnd: (_) => _endVoiceHold(),
|
|
child: Container(
|
|
height: 42,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.background,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: context.jz.line),
|
|
),
|
|
child: Text(
|
|
_voiceProcessing
|
|
? '正在识别…'
|
|
: _voiceHolding
|
|
? '松开结束'
|
|
: '按住 说话',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: _voiceHolding ? accent : context.jz.text,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: TextField(
|
|
key: const ValueKey('keyboard-input'),
|
|
controller: _ctrl,
|
|
focusNode: _inputFocus,
|
|
onTap: _closeComposerPanel,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: sendEnabled ? (_) => _send() : null,
|
|
decoration: InputDecoration(
|
|
hintText: '说句话记一笔,比如:打车花了45',
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 11,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (PublicConfigApi.stickersEnabled) ...[
|
|
SizedBox(width: 5),
|
|
SizedBox(
|
|
width: 36,
|
|
height: 42,
|
|
child: IconButton(
|
|
tooltip: '表情包',
|
|
onPressed: () async {
|
|
if (_stkOpen) {
|
|
_closeComposerPanel();
|
|
return;
|
|
}
|
|
_inputFocus.unfocus();
|
|
await SystemChannels.textInput.invokeMethod<void>(
|
|
'TextInput.hide',
|
|
);
|
|
await Future<void>.delayed(const Duration(milliseconds: 120));
|
|
if (!mounted) return;
|
|
setState(() => _composerPanel = _ComposerPanel.stickers);
|
|
_stkLoad();
|
|
},
|
|
icon: AppIcons.icon(
|
|
AppIcons.smile,
|
|
size: 21,
|
|
color: _stkOpen ? accent : context.jz.text2,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
SizedBox(width: 6),
|
|
],
|
|
SizedBox(
|
|
width: 50,
|
|
height: 38,
|
|
child: FilledButton(
|
|
onPressed: sendEnabled ? _send : null,
|
|
style: FilledButton.styleFrom(
|
|
padding: EdgeInsets.zero,
|
|
backgroundColor: accent,
|
|
disabledBackgroundColor: context.jz.line,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
),
|
|
child: Text(
|
|
'发送',
|
|
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_loaded) {
|
|
return Center(
|
|
child: CircularProgressIndicator(color: AppTheme.ai, strokeWidth: 2),
|
|
);
|
|
}
|
|
if (_loadError case final error? when _msgs.isEmpty) {
|
|
return AsyncErrorView(message: error, onRetry: _hist);
|
|
}
|
|
if (widget.aiMode && _msgs.isEmpty) {
|
|
return ValueListenableBuilder<CompanionDisplay>(
|
|
valueListenable: PublicConfigApi.companionNotifier,
|
|
builder: (_, companion, __) {
|
|
return AiWelcomePage(
|
|
onSend: _send,
|
|
companionName: companion.name,
|
|
companionAvatarKey: companion.avatarKey,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return PopScope(
|
|
canPop: _composerPanel == _ComposerPanel.none,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (!didPop) _closeComposerPanel();
|
|
},
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView.builder(
|
|
controller: _sc,
|
|
padding: const EdgeInsets.all(14),
|
|
itemCount: _msgs.length,
|
|
itemBuilder: (c, i) => _Row(m: _msgs[i], undo: _undo),
|
|
),
|
|
),
|
|
SizedBox(
|
|
height: 40,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
children: [
|
|
..._q.map(
|
|
(p) => Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ActionChip(
|
|
label: Text(
|
|
p,
|
|
style: TextStyle(fontSize: 11, color: context.jz.text2),
|
|
),
|
|
backgroundColor: context.jz.background,
|
|
side: BorderSide.none,
|
|
onPressed: () => _send(p),
|
|
),
|
|
),
|
|
),
|
|
if (PublicConfigApi.imageEnabled)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ActionChip(
|
|
avatar: AppIcons.icon(
|
|
AppIcons.camera,
|
|
size: 16,
|
|
color: AppTheme.ai,
|
|
),
|
|
label: Text(
|
|
'附件',
|
|
style: TextStyle(fontSize: 11, color: context.jz.text2),
|
|
),
|
|
backgroundColor: context.jz.background,
|
|
side: BorderSide.none,
|
|
onPressed: _openAttachment,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_inputBar(context),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: _stkOpen && PublicConfigApi.stickersEnabled
|
|
? Container(
|
|
color: context.jz.card,
|
|
padding: EdgeInsets.only(
|
|
left: 14,
|
|
right: 14,
|
|
top: 10,
|
|
bottom: 14 + MediaQuery.of(context).padding.bottom,
|
|
),
|
|
child: GridView.count(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
crossAxisCount: 4,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 0.78,
|
|
children: _buildStickerGrid(),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Row extends StatelessWidget {
|
|
final ChatMsg m;
|
|
final void Function(TxItem) undo;
|
|
const _Row({required this.m, required this.undo});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMe = m.role == 'user';
|
|
Widget bubble;
|
|
|
|
if (m.type == 'bill_card' && m.transaction != null) {
|
|
bubble = _Bill(t: m.transaction!, u: undo);
|
|
} else if (m.type == 'bill_card') {
|
|
bubble = const _DeletedBill();
|
|
} else if (m.type == 'sticker') {
|
|
final (asset, color) =
|
|
stickerStyle[m.content] ?? (AppIcons.tag, context.jz.text3);
|
|
bubble = Container(
|
|
width: 96,
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Center(child: AppIcons.icon(asset, size: 46, color: color)),
|
|
);
|
|
} else if (!isMe && m.content.isEmpty) {
|
|
bubble = Container(
|
|
constraints: const BoxConstraints(maxWidth: 170),
|
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(3),
|
|
topRight: Radius.circular(15),
|
|
bottomLeft: Radius.circular(15),
|
|
bottomRight: Radius.circular(15),
|
|
),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppTheme.ai,
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'正在回复…',
|
|
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
bubble = Container(
|
|
constraints: const BoxConstraints(maxWidth: 270),
|
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
|
decoration: BoxDecoration(
|
|
color: isMe ? AppTheme.primary : context.jz.card,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(isMe ? 15 : 3),
|
|
topRight: Radius.circular(isMe ? 3 : 15),
|
|
bottomLeft: const Radius.circular(15),
|
|
bottomRight: const Radius.circular(15),
|
|
),
|
|
border: isMe ? null : Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Text(
|
|
m.content,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
height: 1.5,
|
|
color: isMe ? Colors.white : context.jz.text,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 11),
|
|
child: Row(
|
|
mainAxisAlignment: isMe
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!isMe) ...[
|
|
CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: AppTheme.ai,
|
|
child: AppIcons.icon(
|
|
AppIcons.keyMap[PublicConfigApi.companionAvatarKey] ??
|
|
AppIcons.cat,
|
|
size: 17,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
bubble,
|
|
if (isMe) ...[
|
|
SizedBox(width: 8),
|
|
CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: context.jz.line,
|
|
child: AppIcons.icon(
|
|
AppIcons.user,
|
|
size: 17,
|
|
color: context.jz.text2,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DeletedBill extends StatelessWidget {
|
|
const _DeletedBill();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 230,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.delete_outline, size: 20, color: context.jz.text3),
|
|
SizedBox(width: 9),
|
|
Expanded(
|
|
child: Text(
|
|
'账单已永久删除',
|
|
style: TextStyle(fontSize: 12, color: context.jz.text3),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Bill extends StatelessWidget {
|
|
final TxItem t;
|
|
final void Function(TxItem) u;
|
|
const _Bill({required this.t, required this.u});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 230,
|
|
decoration: BoxDecoration(
|
|
color: context.jz.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.jz.line, width: 0.5),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
decoration: BoxDecoration(
|
|
color: t.isIncome ? AppTheme.primary : AppTheme.ai,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(13),
|
|
),
|
|
),
|
|
child: Text(
|
|
t.isIncome ? 'AI 已记录收入' : 'AI 已记录支出',
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
CategoryIconBox(
|
|
iconKey: t.categoryIcon,
|
|
colorKey: t.categoryColor,
|
|
size: 36,
|
|
),
|
|
SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${t.isIncome ? '+' : '-'}¥${t.amount.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: t.isIncome ? AppTheme.primary : context.jz.text,
|
|
),
|
|
),
|
|
Text(
|
|
'${t.categoryName}${t.paymentMethod != null ? ' · ${t.paymentMethod}' : ''}',
|
|
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 0.5, color: context.jz.line),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
'已入账',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
color: AppTheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Container(width: 0.5, height: 32, color: context.jz.line),
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () => u(t),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
'撤销',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 11.5, color: context.jz.text2),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ChatTabPage extends StatefulWidget {
|
|
const ChatTabPage({super.key});
|
|
|
|
@override
|
|
State<ChatTabPage> createState() => _ChatTabPageState();
|
|
}
|
|
|
|
class _ChatTabPageState extends State<ChatTabPage> {
|
|
final _chatController = ChatPageController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: SessionStore.instance,
|
|
builder: (context, _) {
|
|
final accessState = currentAiAccessState();
|
|
return ValueListenableBuilder<CompanionDisplay>(
|
|
valueListenable: PublicConfigApi.companionNotifier,
|
|
builder: (_, companion, __) {
|
|
final status = switch (accessState) {
|
|
AiAccessState.guest => '登录后可用',
|
|
AiAccessState.reauthenticate => '需要重新登录',
|
|
AiAccessState.cloudDisabled => '云连接已关闭',
|
|
null => '在线',
|
|
};
|
|
final statusColor = accessState == null
|
|
? AppTheme.primary
|
|
: AppTheme.orange;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
children: [
|
|
Text(
|
|
companion.name,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Text(
|
|
status,
|
|
style: TextStyle(fontSize: 10, color: statusColor),
|
|
),
|
|
],
|
|
),
|
|
actions: accessState == null
|
|
? [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 10),
|
|
child: Tooltip(
|
|
message: '清理上下文',
|
|
child: Semantics(
|
|
button: true,
|
|
label: '清理聊天上下文',
|
|
child: Material(
|
|
color: context.jz.aiBackground,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
onTap: _chatController.clearContext,
|
|
customBorder: const CircleBorder(),
|
|
child: SizedBox(
|
|
width: 38,
|
|
height: 38,
|
|
child: Icon(
|
|
Icons.restart_alt_rounded,
|
|
size: 20,
|
|
color: AppTheme.ai,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
body: accessState == null
|
|
? ChatPage(controller: _chatController)
|
|
: AiAccessGate(state: accessState),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|