Files
jizhi/frontend/lib/features/chat/chat_page.dart
T

1368 lines
46 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 hasText = _ctrl.text.trim().isNotEmpty;
final sendEnabled = hasText && !_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),
],
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.9, end: 1).animate(animation),
child: child,
),
),
child: hasText
? SizedBox(
key: const ValueKey('send-message'),
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,
),
),
),
)
: PublicConfigApi.imageEnabled
? SizedBox(
key: const ValueKey('add-attachment'),
width: 42,
height: 42,
child: IconButton(
tooltip: '添加附件',
onPressed: _sending ? null : _openAttachment,
icon: AppIcons.icon(
AppIcons.plus,
size: 22,
color: _sending ? context.jz.text3 : context.jz.text2,
),
padding: EdgeInsets.zero,
),
)
: const SizedBox.shrink(key: ValueKey('no-composer-action')),
),
),
],
),
);
}
@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),
),
),
),
],
),
),
_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.aiDisabled => 'AI 功能已关闭',
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),
);
},
);
},
);
}
}