diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 5d94745..b948273 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -57,34 +57,34 @@ android:process=":recognition" android:stopWithTask="false"/> - + + android:name=".PaymentNotificationListenerService" + android:exported="true" + android:label="@string/notification_listener_label" + android:stopWithTask="false" + android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"> - + @@ -95,10 +95,9 @@ + android:authorities="${applicationId}.recognition.bridge" + android:exported="false" + android:grantUriPermissions="false"/> { - val response = RecognitionBridge.call( + private fun recognitionStatus(): Map { + val response = RecognitionBridge.call( this, RecognitionBridgeProvider.METHOD_STATUS, ) @@ -787,13 +787,27 @@ class MainActivity : FlutterActivity() { contentResolver, "enabled_notification_listeners", ).orEmpty() - val notificationAuthorized = enabledListeners + val notificationAuthorized = enabledListeners .split(':') .mapNotNull(ComponentName::unflattenFromString) - .any { it == ComponentName(this, PaymentNotificationListenerService::class.java) } - return mapOf( - "accessibilityAuthorized" to isAccessibilityEnabledInSystem(), - "accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true), + .any { it == ComponentName(this, PaymentNotificationListenerService::class.java) } + val accessibilityAuthorized = isAccessibilityEnabledInSystem() + val accessibilityConnected = 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, "notificationConnected" to (response?.getBoolean("notificationConnected") == true), "postNotificationsGranted" to ( diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt index b45c1d8..937c634 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionBridgeProvider.kt @@ -22,6 +22,9 @@ class RecognitionBridgeProvider : ContentProvider() { return when (method) { METHOD_STATUS -> Bundle().apply { putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected) + putLong("accessibilityLastConnectedAt", ScreenshotAccessibilityService.lastConnectedAt) + putLong("accessibilityLastDisconnectedAt", ScreenshotAccessibilityService.lastDisconnectedAt) + putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT) putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected) putString("settings", RecognitionSettings.statusJson(appContext)) putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus()) @@ -143,6 +146,7 @@ class RecognitionBridgeProvider : ContentProvider() { private data class CaptureResult(val path: String?, val error: String?) companion object { + private val PROCESS_STARTED_AT = System.currentTimeMillis() const val METHOD_STATUS = "status" const val METHOD_SET_TOGGLE = "setToggle" const val METHOD_SET_RUNTIME = "setRuntime" diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt index 9805f4f..fc12713 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt @@ -78,6 +78,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { } activeInstance = this isConnected = true + lastConnectedAt = System.currentTimeMillis() Log.i(TAG, "Accessibility recognition service connected") } @@ -363,6 +364,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { private fun disconnect() { if (activeInstance === this) activeInstance = null isConnected = false + lastDisconnectedAt = System.currentTimeMillis() pendingRetry = null retryTimeout = null pendingVisualCapture = null @@ -1270,6 +1272,14 @@ class ScreenshotAccessibilityService : AccessibilityService() { var isConnected = false private set + @Volatile + var lastConnectedAt = 0L + private set + + @Volatile + var lastDisconnectedAt = 0L + private set + fun requestScreenshot( showResult: Boolean, delayMs: Long = 0L, diff --git a/frontend/lib/app/app.dart b/frontend/lib/app/app.dart index 1ebda3b..9a9a90c 100644 --- a/frontend/lib/app/app.dart +++ b/frontend/lib/app/app.dart @@ -49,7 +49,6 @@ final router = GoRouter( refreshListenable: SessionStore.instance, redirect: (_, state) { final aiOnly = - state.matchedLocation == '/chat' || state.matchedLocation == '/ai-mode' || state.matchedLocation == '/companion'; if (aiOnly && @@ -86,7 +85,15 @@ final router = GoRouter( path: '/categories', 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( path: '/legal/:kind', builder: (_, state) => LegalDocumentPage( @@ -175,8 +182,7 @@ class _MiaoJiAppState extends State with WidgetsBindingObserver { if (SessionStore.instance.hasSession) { await _runSafely(CurrentLedgerStore.instance.loadCached); } - await _runSafely(RecognitionImportService.configureNativeContext); - await _runSafely(RecognitionImportService.importAutomatic); + await _runSafely(_restoreRecognitionServices); await _runSafely(PushService.instance.initialize); if (mounted) setState(() {}); unawaited(_refreshRemoteState()); @@ -184,12 +190,17 @@ class _MiaoJiAppState extends State with WidgetsBindingObserver { Future _resumeServices() async { unawaited(ApiClient.instance.probe()); - await _runSafely(RecognitionImportService.configureNativeContext); - await _runSafely(RecognitionImportService.importAutomatic); + await _runSafely(_restoreRecognitionServices); await _runSafely(PushService.instance.refresh); unawaited(_refreshRemoteState()); } + Future _restoreRecognitionServices() async { + await RecognitionImportService.configureNativeContext(); + await ScreenshotChannel.waitForAccessibilityConnection(); + await RecognitionImportService.importAutomatic(); + } + Future _refreshRemoteState() async { await Future.wait([ _runSafely(PublicConfigApi.init), diff --git a/frontend/lib/features/chat/chat_page.dart b/frontend/lib/features/chat/chat_page.dart index 1016683..d9c65f7 100644 --- a/frontend/lib/features/chat/chat_page.dart +++ b/frontend/lib/features/chat/chat_page.dart @@ -743,7 +743,8 @@ class _ChatPageState extends State { } 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 { ), 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(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 { ), ), ), - 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 { final status = switch (accessState) { AiAccessState.guest => '登录后可用', AiAccessState.reauthenticate => '需要重新登录', + AiAccessState.aiDisabled => 'AI 功能已关闭', AiAccessState.cloudDisabled => '云连接已关闭', null => '在线', }; diff --git a/frontend/lib/features/home/pages/main_shell.dart b/frontend/lib/features/home/pages/main_shell.dart index c06e4b5..1207bcc 100644 --- a/frontend/lib/features/home/pages/main_shell.dart +++ b/frontend/lib/features/home/pages/main_shell.dart @@ -25,14 +25,6 @@ class _MainShellState extends State { 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 { _tab('明细', AppIcons.home, 0), _tab('统计', AppIcons.chart, 1), SizedBox(width: 72, child: _fab()), - if (showAiEntry) - ValueListenableBuilder( - valueListenable: PublicConfigApi.companionNotifier, - builder: (_, companion, __) => - _tab(companion.name, AppIcons.chat, 2), - ), + ValueListenableBuilder( + valueListenable: PublicConfigApi.companionNotifier, + builder: (_, companion, __) => + _tab(companion.name, AppIcons.chat, 2), + ), _tab('我的', AppIcons.user, 3), ], ), diff --git a/frontend/lib/features/settings/me_page.dart b/frontend/lib/features/settings/me_page.dart index 6b5969b..0264930 100644 --- a/frontend/lib/features/settings/me_page.dart +++ b/frontend/lib/features/settings/me_page.dart @@ -264,13 +264,6 @@ class _MePageState extends State { '预算管理', 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, diff --git a/frontend/lib/features/settings/screenshot_settings_page.dart b/frontend/lib/features/settings/screenshot_settings_page.dart index 19a4e6f..db254ce 100644 --- a/frontend/lib/features/settings/screenshot_settings_page.dart +++ b/frontend/lib/features/settings/screenshot_settings_page.dart @@ -32,7 +32,7 @@ class _ScreenshotSettingsPageState extends State 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 _check().whenComplete(() => _diagnosticRefreshInFlight = false); } - Future _check() async { + Future _check({bool waitForConnection = false}) async { try { - var status = await ScreenshotChannel.recognitionStatus(); + var status = waitForConnection + ? await ScreenshotChannel.waitForAccessibilityConnection() + : await ScreenshotChannel.recognitionStatus(); final invalid = [ if (status.accessibilityEvents && (!status.accessibilityAuthorized || @@ -180,7 +182,7 @@ class _ScreenshotSettingsPageState extends State Future _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 '仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 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('前往系统设置'), + ), + ], ), ], ], diff --git a/frontend/lib/features/stats/report_page.dart b/frontend/lib/features/stats/report_page.dart index 1547c69..f8ec7ed 100644 --- a/frontend/lib/features/stats/report_page.dart +++ b/frontend/lib/features/stats/report_page.dart @@ -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 createState() => _ReportPageState(); @@ -33,6 +36,12 @@ class _ReportPageState extends State { @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()); diff --git a/frontend/lib/features/stats/stats_page.dart b/frontend/lib/features/stats/stats_page.dart index e26e2cf..5dc6c97 100644 --- a/frontend/lib/features/stats/stats_page.dart +++ b/frontend/lib/features/stats/stats_page.dart @@ -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 { 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 { ); } - 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, - ), - ), - ), - ], + ), ), ); } diff --git a/frontend/lib/shared/services/screenshot_channel.dart b/frontend/lib/shared/services/screenshot_channel.dart index e8c26ff..ddfeaa6 100644 --- a/frontend/lib/shared/services/screenshot_channel.dart +++ b/frontend/lib/shared/services/screenshot_channel.dart @@ -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 { final bool accessibilityAuthorized; final bool accessibilityConnected; + final AccessibilityConnectionState accessibilityConnectionState; + final DateTime? accessibilityLastConnectedAt; + final DateTime? accessibilityLastDisconnectedAt; + final DateTime? recognitionProcessStartedAt; final bool notificationAuthorized; final bool notificationConnected; final bool postNotificationsGranted; @@ -81,6 +111,11 @@ class RecognitionStatus { const RecognitionStatus({ required this.accessibilityAuthorized, required this.accessibilityConnected, + this.accessibilityConnectionState = + AccessibilityConnectionState.unauthorized, + this.accessibilityLastConnectedAt, + this.accessibilityLastDisconnectedAt, + this.recognitionProcessStartedAt, required this.notificationAuthorized, required this.notificationConnected, required this.postNotificationsGranted, @@ -101,10 +136,28 @@ class RecognitionStatus { final settings = rawSettings == null || rawSettings.isEmpty ? const {} : jsonDecode(rawSettings) as Map; + 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( - accessibilityAuthorized: - value['accessibilityAuthorized'] as bool? ?? false, - accessibilityConnected: value['accessibilityConnected'] as bool? ?? false, + accessibilityAuthorized: accessibilityAuthorized, + accessibilityConnected: accessibilityConnected, + accessibilityConnectionState: AccessibilityConnectionState.parse( + value['accessibilityConnectionState'], + authorized: accessibilityAuthorized, + connected: accessibilityConnected, + ), + accessibilityLastConnectedAt: epochDate('accessibilityLastConnectedAt'), + accessibilityLastDisconnectedAt: epochDate( + 'accessibilityLastDisconnectedAt', + ), + recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'), notificationAuthorized: value['notificationAuthorized'] as bool? ?? false, notificationConnected: value['notificationConnected'] as bool? ?? false, 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 { @@ -403,6 +469,31 @@ class ScreenshotChannel { } } + static Future 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.delayed(interval); + status = await recognitionStatus(); + if (!status.accessibilityAuthorized || + status.accessibilityConnected || + !(status.accessibilityEvents || status.aiScreenshot)) { + return status; + } + } + return status; + } + static Future clearRecognitionDiagnostic() async { try { return await _channel.invokeMethod('clearRecognitionDiagnostic') ?? diff --git a/frontend/lib/shared/widgets/ai_access_gate.dart b/frontend/lib/shared/widgets/ai_access_gate.dart index 771a5b6..b2d90e7 100644 --- a/frontend/lib/shared/widgets/ai_access_gate.dart +++ b/frontend/lib/shared/widgets/ai_access_gate.dart @@ -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_icons.dart'; -enum AiAccessState { guest, reauthenticate, cloudDisabled } +enum AiAccessState { guest, reauthenticate, aiDisabled, cloudDisabled } AiAccessState? currentAiAccessState() { final session = SessionStore.instance; if (session.isGuest) return AiAccessState.guest; if (session.needsReauth) return AiAccessState.reauthenticate; + if (!session.aiEnabled) return AiAccessState.aiDisabled; if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled; return null; } @@ -32,23 +33,27 @@ class _AiAccessGateState extends State { String get _title => switch (widget.state) { AiAccessState.guest => '登录后使用 AI 助手', AiAccessState.reauthenticate => '登录状态已过期', + AiAccessState.aiDisabled => 'AI 功能已关闭', AiAccessState.cloudDisabled => 'AI 功能需要云连接', }; String get _message => switch (widget.state) { AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。', AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。', + AiAccessState.aiDisabled => '当前账号暂未开通 AI,手动记账和统计不受影响。', AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。', }; - String get _actionLabel => switch (widget.state) { + String? get _actionLabel => switch (widget.state) { AiAccessState.guest => '登录后使用', AiAccessState.reauthenticate => '重新登录', + AiAccessState.aiDisabled => null, AiAccessState.cloudDisabled => '开启云同步', }; Future _act() async { if (_busy) return; + if (widget.state == AiAccessState.aiDisabled) return; if (widget.onAction != null) { await widget.onAction!(); return; @@ -115,15 +120,17 @@ class _AiAccessGateState extends State { height: 1.6, ), ), - SizedBox(height: 22), - SizedBox( - width: double.infinity, - child: JzActionButton( - label: _actionLabel, - loading: _busy, - onPressed: _busy ? null : _act, + if (_actionLabel case final label?) ...[ + SizedBox(height: 22), + SizedBox( + width: double.infinity, + child: JzActionButton( + label: label, + loading: _busy, + onPressed: _busy ? null : _act, + ), ), - ), + ], if (widget.state == AiAccessState.guest) ...[ SizedBox(height: 12), Text( diff --git a/frontend/test/api_models_test.dart b/frontend/test/api_models_test.dart index 61d3315..5de18e2 100644 --- a/frontend/test/api_models_test.dart +++ b/frontend/test/api_models_test.dart @@ -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/sse_frame_accumulator.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'; void main() { @@ -176,6 +177,38 @@ void main() { 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 个且键值不重复', () { final keys = AppIcons.categoryCatalog.map((item) => item.key).toList(); expect(keys.length, greaterThanOrEqualTo(32)); diff --git a/frontend/test/visual_contract_test.dart b/frontend/test/visual_contract_test.dart index 294b2ea..4482fcc 100644 --- a/frontend/test/visual_contract_test.dart +++ b/frontend/test/visual_contract_test.dart @@ -65,12 +65,12 @@ void main() { ).readAsStringSync(); final addPage = File('lib/features/add/add_page.dart').readAsStringSync(); final chat = File('lib/features/chat/chat_page.dart').readAsStringSync(); - final companion = File( - 'lib/features/settings/companion_page.dart', - ).readAsStringSync(); - final onboarding = File( - 'lib/features/onboarding/pages/onboarding_page.dart', - ).readAsStringSync(); + final companion = File( + 'lib/features/settings/companion_page.dart', + ).readAsStringSync(); + final onboarding = File( + 'lib/features/onboarding/pages/onboarding_page.dart', + ).readAsStringSync(); final me = File('lib/features/settings/me_page.dart').readAsStringSync(); final report = File( 'lib/features/stats/report_page.dart', @@ -79,30 +79,30 @@ void main() { expect(controls, contains(': context.jz.card')); expect(addPage, isNot(contains('selected ? _activeColor : Colors.white'))); expect(chat, isNot(contains('isMe ? AppTheme.primary : Colors.white'))); - expect( - companion, - isNot(contains('context.jz.aiBackground : Colors.white')), - ); - expect(onboarding, contains(': context.jz.card')); - expect( - onboarding, - isNot(contains('context.jz.aiBackground : Colors.white')), - ); + expect( + companion, + isNot(contains('context.jz.aiBackground : Colors.white')), + ); + expect(onboarding, contains(': context.jz.card')); + expect( + onboarding, + isNot(contains('context.jz.aiBackground : Colors.white')), + ); expect(me, isNot(contains('context.jz.primaryBackground : Colors.white'))); - expect(report, isNot(contains('selected ? Colors.white'))); - }); - - test('首次引导只提交服务端实际返回的 AI 伙伴配置', () { - final onboarding = File( - 'lib/features/onboarding/pages/onboarding_page.dart', - ).readAsStringSync(); - - expect(onboarding, contains('_hasValidCatalogSelection')); - expect(onboarding, contains('AI 伙伴配置暂不可用')); - expect(onboarding, contains('重新加载')); - expect(onboarding, isNot(contains('_fallbackAvatars'))); - expect(onboarding, isNot(contains('_fallbackPersonas'))); - }); + expect(report, isNot(contains('selected ? Colors.white'))); + }); + + test('首次引导只提交服务端实际返回的 AI 伙伴配置', () { + final onboarding = File( + 'lib/features/onboarding/pages/onboarding_page.dart', + ).readAsStringSync(); + + expect(onboarding, contains('_hasValidCatalogSelection')); + expect(onboarding, contains('AI 伙伴配置暂不可用')); + expect(onboarding, contains('重新加载')); + expect(onboarding, isNot(contains('_fallbackAvatars'))); + expect(onboarding, isNot(contains('_fallbackPersonas'))); + }); test('聊天附件只保留拍照和相册导入', () { final source = File('lib/features/chat/chat_page.dart').readAsStringSync(); @@ -114,6 +114,49 @@ void main() { 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('表情包具备离线缓存、内置兜底和展开刷新', () { final api = File('lib/shared/api/business_api.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('_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( + '', + ).firstMatch(manifest)?.group(0); + expect(declaration, isNotNull); + expect(declaration, contains('android:stopWithTask="false"')); + expect(declaration, isNot(contains('android:process='))); + } + final provider = RegExp( + '', + ).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"')); + }); }