feat: refine AI navigation and accessibility recovery

This commit is contained in:
2026-08-26 22:58:26 +08:00
parent c6f98b5445
commit 9ccbdfead4
15 changed files with 521 additions and 203 deletions
+54 -34
View File
@@ -743,7 +743,8 @@ class _ChatPageState extends State<ChatPage> {
}
Widget _inputBar(BuildContext context) {
final sendEnabled = _ctrl.text.trim().isNotEmpty && !_sending;
final hasText = _ctrl.text.trim().isNotEmpty;
final sendEnabled = hasText && !_sending;
final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
return Container(
color: context.jz.card,
@@ -866,23 +867,59 @@ class _ChatPageState extends State<ChatPage> {
),
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),
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: Text(
'发送',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700),
),
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')),
),
),
],
@@ -948,24 +985,6 @@ class _ChatPageState extends State<ChatPage> {
),
),
),
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,
),
),
],
),
),
@@ -1280,6 +1299,7 @@ class _ChatTabPageState extends State<ChatTabPage> {
final status = switch (accessState) {
AiAccessState.guest => '登录后可用',
AiAccessState.reauthenticate => '需要重新登录',
AiAccessState.aiDisabled => 'AI 功能已关闭',
AiAccessState.cloudDisabled => '云连接已关闭',
null => '在线',
};
@@ -25,14 +25,6 @@ class _MainShellState extends State<MainShell> {
return AnimatedBuilder(
animation: SessionStore.instance,
builder: (context, _) {
final session = SessionStore.instance;
final aiEnabled = session.aiEnabled;
final showAiEntry = session.isGuest || aiEnabled;
if (!showAiEntry && widget.shell.currentIndex == 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shell.goBranch(0);
});
}
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@@ -58,12 +50,11 @@ class _MainShellState extends State<MainShell> {
_tab('明细', AppIcons.home, 0),
_tab('统计', AppIcons.chart, 1),
SizedBox(width: 72, child: _fab()),
if (showAiEntry)
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) =>
_tab(companion.name, AppIcons.chat, 2),
),
ValueListenableBuilder<CompanionDisplay>(
valueListenable: PublicConfigApi.companionNotifier,
builder: (_, companion, __) =>
_tab(companion.name, AppIcons.chat, 2),
),
_tab('我的', AppIcons.user, 3),
],
),
@@ -264,13 +264,6 @@ class _MePageState extends State<MePage> {
'预算管理',
onTap: () => context.push('/budget'),
),
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
session.aiEnabled ? 'AI 报告' : '报告',
onTap: () => context.push('/report'),
),
_row(
AppIcons.tag,
context.jz.primaryBackground,
@@ -32,7 +32,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check();
_check(waitForConnection: true);
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
@@ -67,9 +67,11 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check() async {
Future<void> _check({bool waitForConnection = false}) async {
try {
var status = await ScreenshotChannel.recognitionStatus();
var status = waitForConnection
? await ScreenshotChannel.waitForAccessibilityConnection()
: await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
@@ -180,7 +182,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check();
await _check(waitForConnection: true);
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
@@ -431,7 +433,13 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnectionLabel,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
onRetry:
status.accessibilityAuthorized &&
!status.accessibilityConnected
? () => _check(waitForConnection: true)
: null,
child: Column(
children: [
JzSwitchTile(
@@ -888,7 +896,7 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
'无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
@@ -932,6 +940,7 @@ class _RecognitionCard extends StatelessWidget {
final String? statusLabel;
final Widget child;
final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
const _RecognitionCard({
required this.icon,
@@ -942,6 +951,7 @@ class _RecognitionCard extends StatelessWidget {
required this.child,
this.statusLabel,
this.onOpenSettings,
this.onRetry,
});
@override
@@ -1012,42 +1022,25 @@ class _RecognitionCard extends StatelessWidget {
),
const SizedBox(height: 6),
child,
if (onOpenSettings != null) ...[
if (onOpenSettings != null || onRetry != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Semantics(
button: true,
label: '打开系统权限设置',
child: InkWell(
onTap: onOpenSettings,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_outlined,
size: 17,
color: AppTheme.primary,
),
const SizedBox(width: 6),
Text(
'系统权限设置',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (onRetry != null)
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新检测'),
),
),
),
if (onOpenSettings != null)
TextButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_outlined, size: 18),
label: const Text('前往系统设置'),
),
],
),
],
],
+10 -1
View File
@@ -16,7 +16,10 @@ import 'package:miaoji_zhang/shared/services/session_store.dart';
enum _ReportKind { weekly, monthly, yearly }
class ReportPage extends StatefulWidget {
const ReportPage({super.key});
final String? initialPeriod;
final DateTime? initialAnchor;
const ReportPage({super.key, this.initialPeriod, this.initialAnchor});
@override
State<ReportPage> createState() => _ReportPageState();
@@ -33,6 +36,12 @@ class _ReportPageState extends State<ReportPage> {
@override
void initState() {
super.initState();
_anchor = widget.initialAnchor ?? ShanghaiTime.now;
_kind = switch (widget.initialPeriod) {
'week' => _ReportKind.weekly,
'year' => _ReportKind.yearly,
_ => _ReportKind.monthly,
};
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
CurrentLedgerStore.instance.addListener(_load);
unawaited(_load());
+96 -32
View File
@@ -3,9 +3,12 @@ import 'dart:async';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
@@ -161,8 +164,7 @@ class _StatsPageState extends State<StatsPage> {
else ...[
_categoryCard(stats),
SizedBox(height: 10),
if (stats.analysis case final analysis?)
_analysisCard(analysis),
_reportCard(stats.analysis),
SizedBox(height: 10),
_trendCard(stats),
if (stats.byCategory.isNotEmpty) ...[
@@ -318,41 +320,103 @@ class _StatsPageState extends State<StatsPage> {
);
}
Widget _analysisCard(String text) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.aiBackground,
Widget _reportCard(String? analysis) {
final aiEnabled = SessionStore.instance.aiEnabled;
final accent = aiEnabled ? AppTheme.ai : AppTheme.primary;
final background = aiEnabled
? context.jz.aiBackground
: context.jz.primaryBackground;
final copy = analysis?.trim().isNotEmpty == true
? analysis!.trim()
: '查看本周期的收支变化、分类排行和消费高峰。';
final route = Uri(
path: '/report',
queryParameters: {'period': _period, 'date': _anchor.toIso8601String()},
).toString();
return Semantics(
button: true,
label: aiEnabled ? '查看 AI 报告' : '查看周期报告',
child: Material(
color: background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
),
child: Row(
children: [
Container(
width: 30,
height: 30,
child: InkWell(
onTap: () => context.push(route),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: accent.withValues(alpha: 0.12)),
),
child: Icon(
Icons.auto_awesome_rounded,
size: 16,
color: AppTheme.ai,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
aiEnabled
? Icons.auto_awesome_rounded
: Icons.assessment_outlined,
size: 17,
color: accent,
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aiEnabled
? '${PublicConfigApi.companionName}的 AI 报告'
: '周期报告',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: accent,
),
),
SizedBox(height: 5),
Text(
copy,
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'查看完整报告',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: accent,
),
),
SizedBox(width: 2),
Icon(
Icons.chevron_right_rounded,
size: 17,
color: accent,
),
],
),
],
),
),
],
),
),
SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.5,
),
),
),
],
),
),
);
}