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
@@ -57,34 +57,34 @@
android:process=":recognition" android:process=":recognition"
android:stopWithTask="false"/> android:stopWithTask="false"/>
<service <service
android:name=".ScreenshotTileService" android:name=".ScreenshotTileService"
android:exported="true" android:exported="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/screenshot_tile_label" android:label="@string/screenshot_tile_label"
android:process=":recognition" android:stopWithTask="false"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/> <action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".PaymentNotificationListenerService" android:name=".PaymentNotificationListenerService"
android:exported="true" android:exported="true"
android:label="@string/notification_listener_label" android:label="@string/notification_listener_label"
android:process=":recognition" android:stopWithTask="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"> android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter> <intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/> <action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".ScreenshotAccessibilityService" android:name=".ScreenshotAccessibilityService"
android:exported="true" android:exported="true"
android:process=":recognition" android:stopWithTask="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter> <intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/> <action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter> </intent-filter>
@@ -95,10 +95,9 @@
<provider <provider
android:name=".RecognitionBridgeProvider" android:name=".RecognitionBridgeProvider"
android:authorities="${applicationId}.recognition.bridge" android:authorities="${applicationId}.recognition.bridge"
android:exported="false" android:exported="false"
android:grantUriPermissions="false" android:grantUriPermissions="false"/>
android:process=":recognition"/>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
@@ -778,8 +778,8 @@ class MainActivity : FlutterActivity() {
}, },
) )
} }
private fun recognitionStatus(): Map<String, Any?> { private fun recognitionStatus(): Map<String, Any?> {
val response = RecognitionBridge.call( val response = RecognitionBridge.call(
this, this,
RecognitionBridgeProvider.METHOD_STATUS, RecognitionBridgeProvider.METHOD_STATUS,
) )
@@ -787,13 +787,27 @@ class MainActivity : FlutterActivity() {
contentResolver, contentResolver,
"enabled_notification_listeners", "enabled_notification_listeners",
).orEmpty() ).orEmpty()
val notificationAuthorized = enabledListeners val notificationAuthorized = enabledListeners
.split(':') .split(':')
.mapNotNull(ComponentName::unflattenFromString) .mapNotNull(ComponentName::unflattenFromString)
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) } .any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
return mapOf( val accessibilityAuthorized = isAccessibilityEnabledInSystem()
"accessibilityAuthorized" to isAccessibilityEnabledInSystem(), val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
"accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true), val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L
val accessibilityConnectionState = when {
!accessibilityAuthorized -> "unauthorized"
accessibilityConnected -> "connected"
recognitionProcessStartedAt > 0L &&
System.currentTimeMillis() - recognitionProcessStartedAt < 5_000L -> "reconnecting"
else -> "disconnected"
}
return mapOf(
"accessibilityAuthorized" to accessibilityAuthorized,
"accessibilityConnected" to accessibilityConnected,
"accessibilityConnectionState" to accessibilityConnectionState,
"accessibilityLastConnectedAt" to (response?.getLong("accessibilityLastConnectedAt") ?: 0L),
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
"recognitionProcessStartedAt" to recognitionProcessStartedAt,
"notificationAuthorized" to notificationAuthorized, "notificationAuthorized" to notificationAuthorized,
"notificationConnected" to (response?.getBoolean("notificationConnected") == true), "notificationConnected" to (response?.getBoolean("notificationConnected") == true),
"postNotificationsGranted" to ( "postNotificationsGranted" to (
@@ -22,6 +22,9 @@ class RecognitionBridgeProvider : ContentProvider() {
return when (method) { return when (method) {
METHOD_STATUS -> Bundle().apply { METHOD_STATUS -> Bundle().apply {
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected) putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
putLong("accessibilityLastConnectedAt", ScreenshotAccessibilityService.lastConnectedAt)
putLong("accessibilityLastDisconnectedAt", ScreenshotAccessibilityService.lastDisconnectedAt)
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected) putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
putString("settings", RecognitionSettings.statusJson(appContext)) putString("settings", RecognitionSettings.statusJson(appContext))
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus()) putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
@@ -143,6 +146,7 @@ class RecognitionBridgeProvider : ContentProvider() {
private data class CaptureResult(val path: String?, val error: String?) private data class CaptureResult(val path: String?, val error: String?)
companion object { companion object {
private val PROCESS_STARTED_AT = System.currentTimeMillis()
const val METHOD_STATUS = "status" const val METHOD_STATUS = "status"
const val METHOD_SET_TOGGLE = "setToggle" const val METHOD_SET_TOGGLE = "setToggle"
const val METHOD_SET_RUNTIME = "setRuntime" const val METHOD_SET_RUNTIME = "setRuntime"
@@ -78,6 +78,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
} }
activeInstance = this activeInstance = this
isConnected = true isConnected = true
lastConnectedAt = System.currentTimeMillis()
Log.i(TAG, "Accessibility recognition service connected") Log.i(TAG, "Accessibility recognition service connected")
} }
@@ -363,6 +364,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
private fun disconnect() { private fun disconnect() {
if (activeInstance === this) activeInstance = null if (activeInstance === this) activeInstance = null
isConnected = false isConnected = false
lastDisconnectedAt = System.currentTimeMillis()
pendingRetry = null pendingRetry = null
retryTimeout = null retryTimeout = null
pendingVisualCapture = null pendingVisualCapture = null
@@ -1270,6 +1272,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
var isConnected = false var isConnected = false
private set private set
@Volatile
var lastConnectedAt = 0L
private set
@Volatile
var lastDisconnectedAt = 0L
private set
fun requestScreenshot( fun requestScreenshot(
showResult: Boolean, showResult: Boolean,
delayMs: Long = 0L, delayMs: Long = 0L,
+17 -6
View File
@@ -49,7 +49,6 @@ final router = GoRouter(
refreshListenable: SessionStore.instance, refreshListenable: SessionStore.instance,
redirect: (_, state) { redirect: (_, state) {
final aiOnly = final aiOnly =
state.matchedLocation == '/chat' ||
state.matchedLocation == '/ai-mode' || state.matchedLocation == '/ai-mode' ||
state.matchedLocation == '/companion'; state.matchedLocation == '/companion';
if (aiOnly && if (aiOnly &&
@@ -86,7 +85,15 @@ final router = GoRouter(
path: '/categories', path: '/categories',
builder: (_, __) => const CategoryManagePage(), builder: (_, __) => const CategoryManagePage(),
), ),
GoRoute(path: '/report', builder: (_, __) => const ReportPage()), GoRoute(
path: '/report',
builder: (_, state) => ReportPage(
initialPeriod: state.uri.queryParameters['period'],
initialAnchor: DateTime.tryParse(
state.uri.queryParameters['date'] ?? '',
),
),
),
GoRoute( GoRoute(
path: '/legal/:kind', path: '/legal/:kind',
builder: (_, state) => LegalDocumentPage( builder: (_, state) => LegalDocumentPage(
@@ -175,8 +182,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
if (SessionStore.instance.hasSession) { if (SessionStore.instance.hasSession) {
await _runSafely(CurrentLedgerStore.instance.loadCached); await _runSafely(CurrentLedgerStore.instance.loadCached);
} }
await _runSafely(RecognitionImportService.configureNativeContext); await _runSafely(_restoreRecognitionServices);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(PushService.instance.initialize); await _runSafely(PushService.instance.initialize);
if (mounted) setState(() {}); if (mounted) setState(() {});
unawaited(_refreshRemoteState()); unawaited(_refreshRemoteState());
@@ -184,12 +190,17 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
Future<void> _resumeServices() async { Future<void> _resumeServices() async {
unawaited(ApiClient.instance.probe()); unawaited(ApiClient.instance.probe());
await _runSafely(RecognitionImportService.configureNativeContext); await _runSafely(_restoreRecognitionServices);
await _runSafely(RecognitionImportService.importAutomatic);
await _runSafely(PushService.instance.refresh); await _runSafely(PushService.instance.refresh);
unawaited(_refreshRemoteState()); unawaited(_refreshRemoteState());
} }
Future<void> _restoreRecognitionServices() async {
await RecognitionImportService.configureNativeContext();
await ScreenshotChannel.waitForAccessibilityConnection();
await RecognitionImportService.importAutomatic();
}
Future<void> _refreshRemoteState() async { Future<void> _refreshRemoteState() async {
await Future.wait([ await Future.wait([
_runSafely(PublicConfigApi.init), _runSafely(PublicConfigApi.init),
+54 -34
View File
@@ -743,7 +743,8 @@ class _ChatPageState extends State<ChatPage> {
} }
Widget _inputBar(BuildContext context) { 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; final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
return Container( return Container(
color: context.jz.card, color: context.jz.card,
@@ -866,23 +867,59 @@ class _ChatPageState extends State<ChatPage> {
), ),
SizedBox(width: 6), SizedBox(width: 6),
], ],
SizedBox( AnimatedSize(
width: 50, duration: const Duration(milliseconds: 180),
height: 38, curve: Curves.easeOutCubic,
child: FilledButton( child: AnimatedSwitcher(
onPressed: sendEnabled ? _send : null, duration: const Duration(milliseconds: 160),
style: FilledButton.styleFrom( transitionBuilder: (child, animation) => FadeTransition(
padding: EdgeInsets.zero, opacity: animation,
backgroundColor: accent, child: ScaleTransition(
disabledBackgroundColor: context.jz.line, scale: Tween<double>(begin: 0.9, end: 1).animate(animation),
shape: RoundedRectangleBorder( child: child,
borderRadius: BorderRadius.circular(11),
), ),
), ),
child: Text( child: hasText
'发送', ? SizedBox(
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700), 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) { final status = switch (accessState) {
AiAccessState.guest => '登录后可用', AiAccessState.guest => '登录后可用',
AiAccessState.reauthenticate => '需要重新登录', AiAccessState.reauthenticate => '需要重新登录',
AiAccessState.aiDisabled => 'AI 功能已关闭',
AiAccessState.cloudDisabled => '云连接已关闭', AiAccessState.cloudDisabled => '云连接已关闭',
null => '在线', null => '在线',
}; };
@@ -25,14 +25,6 @@ class _MainShellState extends State<MainShell> {
return AnimatedBuilder( return AnimatedBuilder(
animation: SessionStore.instance, animation: SessionStore.instance,
builder: (context, _) { 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( return Scaffold(
body: AnimatedSwitcher( body: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -58,12 +50,11 @@ class _MainShellState extends State<MainShell> {
_tab('明细', AppIcons.home, 0), _tab('明细', AppIcons.home, 0),
_tab('统计', AppIcons.chart, 1), _tab('统计', AppIcons.chart, 1),
SizedBox(width: 72, child: _fab()), SizedBox(width: 72, child: _fab()),
if (showAiEntry) ValueListenableBuilder<CompanionDisplay>(
ValueListenableBuilder<CompanionDisplay>( valueListenable: PublicConfigApi.companionNotifier,
valueListenable: PublicConfigApi.companionNotifier, builder: (_, companion, __) =>
builder: (_, companion, __) => _tab(companion.name, AppIcons.chat, 2),
_tab(companion.name, AppIcons.chat, 2), ),
),
_tab('我的', AppIcons.user, 3), _tab('我的', AppIcons.user, 3),
], ],
), ),
@@ -264,13 +264,6 @@ class _MePageState extends State<MePage> {
'预算管理', '预算管理',
onTap: () => context.push('/budget'), onTap: () => context.push('/budget'),
), ),
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
session.aiEnabled ? 'AI 报告' : '报告',
onTap: () => context.push('/report'),
),
_row( _row(
AppIcons.tag, AppIcons.tag,
context.jz.primaryBackground, context.jz.primaryBackground,
@@ -32,7 +32,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_check(); _check(waitForConnection: true);
_diagnosticRefreshTimer = Timer.periodic( _diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2), const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(), (_) => _refreshRunningDiagnostic(),
@@ -67,9 +67,11 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
_check().whenComplete(() => _diagnosticRefreshInFlight = false); _check().whenComplete(() => _diagnosticRefreshInFlight = false);
} }
Future<void> _check() async { Future<void> _check({bool waitForConnection = false}) async {
try { try {
var status = await ScreenshotChannel.recognitionStatus(); var status = waitForConnection
? await ScreenshotChannel.waitForAccessibilityConnection()
: await ScreenshotChannel.recognitionStatus();
final invalid = <String>[ final invalid = <String>[
if (status.accessibilityEvents && if (status.accessibilityEvents &&
(!status.accessibilityAuthorized || (!status.accessibilityAuthorized ||
@@ -180,7 +182,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
Future<void> _resumeAuthorization() async { Future<void> _resumeAuthorization() async {
if (_authorizing) return; if (_authorizing) return;
await _check(); await _check(waitForConnection: true);
final key = _pendingAuthorizationKey; final key = _pendingAuthorizationKey;
final status = _status; final status = _status;
if (key == null || status == null) return; if (key == null || status == null) return;
@@ -431,7 +433,13 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。', '仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized, authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected, connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnectionLabel,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings, onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
onRetry:
status.accessibilityAuthorized &&
!status.accessibilityConnected
? () => _check(waitForConnection: true)
: null,
child: Column( child: Column(
children: [ children: [
JzSwitchTile( JzSwitchTile(
@@ -888,7 +896,7 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,' '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}' '${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。', '否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle( style: TextStyle(
@@ -932,6 +940,7 @@ class _RecognitionCard extends StatelessWidget {
final String? statusLabel; final String? statusLabel;
final Widget child; final Widget child;
final VoidCallback? onOpenSettings; final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
const _RecognitionCard({ const _RecognitionCard({
required this.icon, required this.icon,
@@ -942,6 +951,7 @@ class _RecognitionCard extends StatelessWidget {
required this.child, required this.child,
this.statusLabel, this.statusLabel,
this.onOpenSettings, this.onOpenSettings,
this.onRetry,
}); });
@override @override
@@ -1012,42 +1022,25 @@ class _RecognitionCard extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
child, child,
if (onOpenSettings != null) ...[ if (onOpenSettings != null || onRetry != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Align( Wrap(
alignment: Alignment.centerLeft, spacing: 6,
child: Semantics( runSpacing: 4,
button: true, children: [
label: '打开系统权限设置', if (onRetry != null)
child: InkWell( TextButton.icon(
onTap: onOpenSettings, onPressed: onRetry,
borderRadius: BorderRadius.circular(10), icon: const Icon(Icons.refresh_rounded, size: 18),
child: Padding( label: const Text('重新检测'),
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,
),
),
],
),
), ),
), 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 } enum _ReportKind { weekly, monthly, yearly }
class ReportPage extends StatefulWidget { class ReportPage extends StatefulWidget {
const ReportPage({super.key}); final String? initialPeriod;
final DateTime? initialAnchor;
const ReportPage({super.key, this.initialPeriod, this.initialAnchor});
@override @override
State<ReportPage> createState() => _ReportPageState(); State<ReportPage> createState() => _ReportPageState();
@@ -33,6 +36,12 @@ class _ReportPageState extends State<ReportPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_anchor = widget.initialAnchor ?? ShanghaiTime.now;
_kind = switch (widget.initialPeriod) {
'week' => _ReportKind.weekly,
'year' => _ReportKind.yearly,
_ => _ReportKind.monthly,
};
PublicConfigApi.companionNotifier.addListener(_refreshCompanion); PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
CurrentLedgerStore.instance.addListener(_load); CurrentLedgerStore.instance.addListener(_load);
unawaited(_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:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:flutter/material.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/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.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/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/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.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/async_error_view.dart';
@@ -161,8 +164,7 @@ class _StatsPageState extends State<StatsPage> {
else ...[ else ...[
_categoryCard(stats), _categoryCard(stats),
SizedBox(height: 10), SizedBox(height: 10),
if (stats.analysis case final analysis?) _reportCard(stats.analysis),
_analysisCard(analysis),
SizedBox(height: 10), SizedBox(height: 10),
_trendCard(stats), _trendCard(stats),
if (stats.byCategory.isNotEmpty) ...[ if (stats.byCategory.isNotEmpty) ...[
@@ -318,41 +320,103 @@ class _StatsPageState extends State<StatsPage> {
); );
} }
Widget _analysisCard(String text) { Widget _reportCard(String? analysis) {
return Container( final aiEnabled = SessionStore.instance.aiEnabled;
padding: const EdgeInsets.all(14), final accent = aiEnabled ? AppTheme.ai : AppTheme.primary;
decoration: BoxDecoration( final background = aiEnabled
color: context.jz.aiBackground, ? 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), borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)), child: InkWell(
), onTap: () => context.push(route),
child: Row( borderRadius: BorderRadius.circular(14),
children: [ child: Container(
Container( padding: const EdgeInsets.all(14),
width: 30,
height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.jz.card, borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(10), border: Border.all(color: accent.withValues(alpha: 0.12)),
), ),
child: Icon( child: Row(
Icons.auto_awesome_rounded, crossAxisAlignment: CrossAxisAlignment.start,
size: 16, children: [
color: AppTheme.ai, 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,
),
),
),
],
), ),
); );
} }
@@ -62,9 +62,39 @@ class RecognitionDiagnostic {
} }
} }
enum AccessibilityConnectionState {
unauthorized,
reconnecting,
connected,
disconnected;
static AccessibilityConnectionState parse(
Object? value, {
required bool authorized,
required bool connected,
}) {
return switch (value?.toString()) {
'reconnecting' => AccessibilityConnectionState.reconnecting,
'connected' => AccessibilityConnectionState.connected,
'disconnected' => AccessibilityConnectionState.disconnected,
'unauthorized' => AccessibilityConnectionState.unauthorized,
_ =>
!authorized
? unauthorized
: connected
? AccessibilityConnectionState.connected
: disconnected,
};
}
}
class RecognitionStatus { class RecognitionStatus {
final bool accessibilityAuthorized; final bool accessibilityAuthorized;
final bool accessibilityConnected; final bool accessibilityConnected;
final AccessibilityConnectionState accessibilityConnectionState;
final DateTime? accessibilityLastConnectedAt;
final DateTime? accessibilityLastDisconnectedAt;
final DateTime? recognitionProcessStartedAt;
final bool notificationAuthorized; final bool notificationAuthorized;
final bool notificationConnected; final bool notificationConnected;
final bool postNotificationsGranted; final bool postNotificationsGranted;
@@ -81,6 +111,11 @@ class RecognitionStatus {
const RecognitionStatus({ const RecognitionStatus({
required this.accessibilityAuthorized, required this.accessibilityAuthorized,
required this.accessibilityConnected, required this.accessibilityConnected,
this.accessibilityConnectionState =
AccessibilityConnectionState.unauthorized,
this.accessibilityLastConnectedAt,
this.accessibilityLastDisconnectedAt,
this.recognitionProcessStartedAt,
required this.notificationAuthorized, required this.notificationAuthorized,
required this.notificationConnected, required this.notificationConnected,
required this.postNotificationsGranted, required this.postNotificationsGranted,
@@ -101,10 +136,28 @@ class RecognitionStatus {
final settings = rawSettings == null || rawSettings.isEmpty final settings = rawSettings == null || rawSettings.isEmpty
? const <String, dynamic>{} ? const <String, dynamic>{}
: jsonDecode(rawSettings) as Map<String, dynamic>; : jsonDecode(rawSettings) as Map<String, dynamic>;
final accessibilityAuthorized =
value['accessibilityAuthorized'] as bool? ?? false;
final accessibilityConnected =
value['accessibilityConnected'] as bool? ?? false;
DateTime? epochDate(String key) {
final epoch = (value[key] as num?)?.toInt() ?? 0;
return epoch <= 0 ? null : DateTime.fromMillisecondsSinceEpoch(epoch);
}
return RecognitionStatus( return RecognitionStatus(
accessibilityAuthorized: accessibilityAuthorized: accessibilityAuthorized,
value['accessibilityAuthorized'] as bool? ?? false, accessibilityConnected: accessibilityConnected,
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false, accessibilityConnectionState: AccessibilityConnectionState.parse(
value['accessibilityConnectionState'],
authorized: accessibilityAuthorized,
connected: accessibilityConnected,
),
accessibilityLastConnectedAt: epochDate('accessibilityLastConnectedAt'),
accessibilityLastDisconnectedAt: epochDate(
'accessibilityLastDisconnectedAt',
),
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false, notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
notificationConnected: value['notificationConnected'] as bool? ?? false, notificationConnected: value['notificationConnected'] as bool? ?? false,
postNotificationsGranted: postNotificationsGranted:
@@ -130,6 +183,19 @@ class RecognitionStatus {
), ),
); );
} }
bool get accessibilityNeedsRecovery =>
accessibilityAuthorized &&
!accessibilityConnected &&
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
String get accessibilityConnectionLabel =>
switch (accessibilityConnectionState) {
AccessibilityConnectionState.unauthorized => '未授权',
AccessibilityConnectionState.reconnecting => '正在重连',
AccessibilityConnectionState.connected => '已连接',
AccessibilityConnectionState.disconnected => '服务未连接',
};
} }
class RecognitionCandidate { class RecognitionCandidate {
@@ -403,6 +469,31 @@ class ScreenshotChannel {
} }
} }
static Future<RecognitionStatus> waitForAccessibilityConnection({
Duration timeout = const Duration(seconds: 5),
Duration interval = const Duration(milliseconds: 500),
}) async {
var status = await recognitionStatus();
final recognitionEnabled =
status.accessibilityEvents || status.aiScreenshot;
if (!recognitionEnabled ||
!status.accessibilityAuthorized ||
status.accessibilityConnected) {
return status;
}
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(interval);
status = await recognitionStatus();
if (!status.accessibilityAuthorized ||
status.accessibilityConnected ||
!(status.accessibilityEvents || status.aiScreenshot)) {
return status;
}
}
return status;
}
static Future<bool> clearRecognitionDiagnostic() async { static Future<bool> clearRecognitionDiagnostic() async {
try { try {
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ?? return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
+17 -10
View File
@@ -6,12 +6,13 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
enum AiAccessState { guest, reauthenticate, cloudDisabled } enum AiAccessState { guest, reauthenticate, aiDisabled, cloudDisabled }
AiAccessState? currentAiAccessState() { AiAccessState? currentAiAccessState() {
final session = SessionStore.instance; final session = SessionStore.instance;
if (session.isGuest) return AiAccessState.guest; if (session.isGuest) return AiAccessState.guest;
if (session.needsReauth) return AiAccessState.reauthenticate; if (session.needsReauth) return AiAccessState.reauthenticate;
if (!session.aiEnabled) return AiAccessState.aiDisabled;
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled; if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
return null; return null;
} }
@@ -32,23 +33,27 @@ class _AiAccessGateState extends State<AiAccessGate> {
String get _title => switch (widget.state) { String get _title => switch (widget.state) {
AiAccessState.guest => '登录后使用 AI 助手', AiAccessState.guest => '登录后使用 AI 助手',
AiAccessState.reauthenticate => '登录状态已过期', AiAccessState.reauthenticate => '登录状态已过期',
AiAccessState.aiDisabled => 'AI 功能已关闭',
AiAccessState.cloudDisabled => 'AI 功能需要云连接', AiAccessState.cloudDisabled => 'AI 功能需要云连接',
}; };
String get _message => switch (widget.state) { String get _message => switch (widget.state) {
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。', AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。', AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
AiAccessState.aiDisabled => '当前账号暂未开通 AI,手动记账和统计不受影响。',
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。', AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
}; };
String get _actionLabel => switch (widget.state) { String? get _actionLabel => switch (widget.state) {
AiAccessState.guest => '登录后使用', AiAccessState.guest => '登录后使用',
AiAccessState.reauthenticate => '重新登录', AiAccessState.reauthenticate => '重新登录',
AiAccessState.aiDisabled => null,
AiAccessState.cloudDisabled => '开启云同步', AiAccessState.cloudDisabled => '开启云同步',
}; };
Future<void> _act() async { Future<void> _act() async {
if (_busy) return; if (_busy) return;
if (widget.state == AiAccessState.aiDisabled) return;
if (widget.onAction != null) { if (widget.onAction != null) {
await widget.onAction!(); await widget.onAction!();
return; return;
@@ -115,15 +120,17 @@ class _AiAccessGateState extends State<AiAccessGate> {
height: 1.6, height: 1.6,
), ),
), ),
SizedBox(height: 22), if (_actionLabel case final label?) ...[
SizedBox( SizedBox(height: 22),
width: double.infinity, SizedBox(
child: JzActionButton( width: double.infinity,
label: _actionLabel, child: JzActionButton(
loading: _busy, label: label,
onPressed: _busy ? null : _act, loading: _busy,
onPressed: _busy ? null : _act,
),
), ),
), ],
if (widget.state == AiAccessState.guest) ...[ if (widget.state == AiAccessState.guest) ...[
SizedBox(height: 12), SizedBox(height: 12),
Text( Text(
+33
View File
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart'; import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
void main() { void main() {
@@ -176,6 +177,38 @@ void main() {
expect(report.balance, 2400); expect(report.balance, 2400);
}); });
test('无障碍连接状态区分重连、正常和异常', () {
final reconnecting = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': false,
'accessibilityConnectionState': 'reconnecting',
'accessibilityLastConnectedAt': 1724472000000,
'settings': jsonEncode({'accessibilityEvents': true}),
});
final connected = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': true,
'accessibilityConnectionState': 'connected',
'settings': '{}',
});
final disconnected = RecognitionStatus.fromMap({
'accessibilityAuthorized': true,
'accessibilityConnected': false,
'accessibilityConnectionState': 'disconnected',
'settings': '{}',
});
expect(
reconnecting.accessibilityConnectionState,
AccessibilityConnectionState.reconnecting,
);
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
expect(reconnecting.accessibilityLastConnectedAt, isNotNull);
expect(connected.accessibilityConnectionLabel, '已连接');
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
expect(disconnected.accessibilityNeedsRecovery, isTrue);
});
test('分类图标目录至少 32 个且键值不重复', () { test('分类图标目录至少 32 个且键值不重复', () {
final keys = AppIcons.categoryCatalog.map((item) => item.key).toList(); final keys = AppIcons.categoryCatalog.map((item) => item.key).toList();
expect(keys.length, greaterThanOrEqualTo(32)); expect(keys.length, greaterThanOrEqualTo(32));
+108 -29
View File
@@ -65,12 +65,12 @@ void main() {
).readAsStringSync(); ).readAsStringSync();
final addPage = File('lib/features/add/add_page.dart').readAsStringSync(); final addPage = File('lib/features/add/add_page.dart').readAsStringSync();
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync(); final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
final companion = File( final companion = File(
'lib/features/settings/companion_page.dart', 'lib/features/settings/companion_page.dart',
).readAsStringSync(); ).readAsStringSync();
final onboarding = File( final onboarding = File(
'lib/features/onboarding/pages/onboarding_page.dart', 'lib/features/onboarding/pages/onboarding_page.dart',
).readAsStringSync(); ).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync(); final me = File('lib/features/settings/me_page.dart').readAsStringSync();
final report = File( final report = File(
'lib/features/stats/report_page.dart', 'lib/features/stats/report_page.dart',
@@ -79,30 +79,30 @@ void main() {
expect(controls, contains(': context.jz.card')); expect(controls, contains(': context.jz.card'));
expect(addPage, isNot(contains('selected ? _activeColor : Colors.white'))); expect(addPage, isNot(contains('selected ? _activeColor : Colors.white')));
expect(chat, isNot(contains('isMe ? AppTheme.primary : Colors.white'))); expect(chat, isNot(contains('isMe ? AppTheme.primary : Colors.white')));
expect( expect(
companion, companion,
isNot(contains('context.jz.aiBackground : Colors.white')), isNot(contains('context.jz.aiBackground : Colors.white')),
); );
expect(onboarding, contains(': context.jz.card')); expect(onboarding, contains(': context.jz.card'));
expect( expect(
onboarding, onboarding,
isNot(contains('context.jz.aiBackground : Colors.white')), isNot(contains('context.jz.aiBackground : Colors.white')),
); );
expect(me, isNot(contains('context.jz.primaryBackground : Colors.white'))); expect(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
expect(report, isNot(contains('selected ? Colors.white'))); expect(report, isNot(contains('selected ? Colors.white')));
}); });
test('首次引导只提交服务端实际返回的 AI 伙伴配置', () { test('首次引导只提交服务端实际返回的 AI 伙伴配置', () {
final onboarding = File( final onboarding = File(
'lib/features/onboarding/pages/onboarding_page.dart', 'lib/features/onboarding/pages/onboarding_page.dart',
).readAsStringSync(); ).readAsStringSync();
expect(onboarding, contains('_hasValidCatalogSelection')); expect(onboarding, contains('_hasValidCatalogSelection'));
expect(onboarding, contains('AI 伙伴配置暂不可用')); expect(onboarding, contains('AI 伙伴配置暂不可用'));
expect(onboarding, contains('重新加载')); expect(onboarding, contains('重新加载'));
expect(onboarding, isNot(contains('_fallbackAvatars'))); expect(onboarding, isNot(contains('_fallbackAvatars')));
expect(onboarding, isNot(contains('_fallbackPersonas'))); expect(onboarding, isNot(contains('_fallbackPersonas')));
}); });
test('聊天附件只保留拍照和相册导入', () { test('聊天附件只保留拍照和相册导入', () {
final source = File('lib/features/chat/chat_page.dart').readAsStringSync(); final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
@@ -114,6 +114,49 @@ void main() {
expect(source, isNot(contains('ScreenshotChannel.capture'))); expect(source, isNot(contains('ScreenshotChannel.capture')));
}); });
test('底部导航固定保留 AI 入口且关闭状态在聊天二级页处理', () {
final shell = File(
'lib/features/home/pages/main_shell.dart',
).readAsStringSync();
final router = File('lib/app/app.dart').readAsStringSync();
final gate = File(
'lib/shared/widgets/ai_access_gate.dart',
).readAsStringSync();
expect(shell, isNot(contains('showAiEntry')));
expect(shell, contains('_tab(companion.name, AppIcons.chat, 2)'));
expect(router, isNot(contains("state.matchedLocation == '/chat'")));
expect(gate, contains('AiAccessState.aiDisabled'));
expect(gate, contains('AI 功能已关闭'));
expect(gate, contains('手动记账和统计不受影响'));
});
test('报告入口归入统计并继承当前周期', () {
final stats = File('lib/features/stats/stats_page.dart').readAsStringSync();
final report = File(
'lib/features/stats/report_page.dart',
).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
expect(me, isNot(contains("context.push('/report')")));
expect(stats, contains("path: '/report'"));
expect(stats, contains("'period': _period"));
expect(stats, contains("'date': _anchor.toIso8601String()"));
expect(stats, contains('查看完整报告'));
expect(report, contains('widget.initialPeriod'));
expect(report, contains('widget.initialAnchor'));
});
test('聊天输入为空显示附件加号,有文本时显示发送按钮', () {
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
expect(chat, contains("ValueKey('add-attachment')"));
expect(chat, contains("ValueKey('send-message')"));
expect(chat, contains('child: hasText'));
expect(chat, contains("tooltip: '添加附件'"));
expect(chat, isNot(contains("'附件'")));
});
test('表情包具备离线缓存、内置兜底和展开刷新', () { test('表情包具备离线缓存、内置兜底和展开刷新', () {
final api = File('lib/shared/api/business_api.dart').readAsStringSync(); final api = File('lib/shared/api/business_api.dart').readAsStringSync();
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync(); final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
@@ -236,4 +279,40 @@ void main() {
expect(source, contains('setRecognitionToggle(key, false)')); expect(source, contains('setRecognitionToggle(key, false)'));
expect(source, contains('_BackgroundKeepAliveCard')); expect(source, contains('_BackgroundKeepAliveCard'));
}); });
test('长期识别组件与系统绑定使用默认进程并支持重连状态', () {
final manifest = File(
'android/app/src/main/AndroidManifest.xml',
).readAsStringSync();
final channel = File(
'lib/shared/services/screenshot_channel.dart',
).readAsStringSync();
final activity = File(
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
).readAsStringSync();
for (final component in [
'ScreenshotTileService',
'PaymentNotificationListenerService',
'ScreenshotAccessibilityService',
]) {
final declaration = RegExp(
'<service\\s+android:name="\\.$component"[\\s\\S]*?</service>',
).firstMatch(manifest)?.group(0);
expect(declaration, isNotNull);
expect(declaration, contains('android:stopWithTask="false"'));
expect(declaration, isNot(contains('android:process=')));
}
final provider = RegExp(
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
).firstMatch(manifest)?.group(0);
expect(provider, isNotNull);
expect(provider, isNot(contains('android:process=')));
expect(manifest, contains('android:name=".OneShotProjectionService"'));
expect(manifest, contains('android:process=":recognition"'));
expect(channel, contains('waitForAccessibilityConnection'));
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
expect(activity, contains('"reconnecting"'));
expect(activity, contains('"disconnected"'));
});
} }