feat: refine AI navigation and accessibility recovery
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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('前往系统设置'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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') ??
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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"'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user