Files
jizhi/frontend/lib/features/settings/screenshot_settings_page.dart
T

1221 lines
44 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/recognition_diagnostic_formatter.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class ScreenshotSettingsPage extends StatefulWidget {
const ScreenshotSettingsPage({super.key});
@override
State<ScreenshotSettingsPage> createState() => _ScreenshotSettingsPageState();
}
class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
with WidgetsBindingObserver {
RecognitionStatus? _status;
bool _checking = true;
bool _authorizing = false;
String? _pendingAuthorizationKey;
String? _error;
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
bool _connectionCheckInFlight = false;
bool _keepAliveStarting = false;
bool _recentsProtectionNoticeChecked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check();
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
);
}
@override
void dispose() {
_diagnosticRefreshTimer?.cancel();
_previewExpiryTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future<void>.delayed(
const Duration(milliseconds: 500),
_resumeAuthorization,
);
}
}
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check({bool monitorConnection = true}) async {
try {
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'accessibility_events',
if (status.notificationEvents &&
(!status.notificationAuthorized ||
!status.postNotificationsGranted))
'notification_events',
if (status.aiScreenshot &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'ai_screenshot',
];
for (final key in invalid) {
await ScreenshotChannel.setRecognitionToggle(key, false);
}
if (invalid.isNotEmpty) {
status = await ScreenshotChannel.recognitionStatus();
}
if (!mounted) return;
setState(() {
_status = status;
_checking = false;
_error = null;
});
_schedulePreviewExpiry(status);
unawaited(_showRecentsProtectionNotice(status));
if (monitorConnection &&
status.accessibilityAuthorized &&
!status.accessibilityConnected &&
!status.taskCleanerRecoveryNeeded &&
(status.accessibilityEvents || status.aiScreenshot)) {
unawaited(_monitorAccessibilityConnection());
}
} catch (_) {
if (!mounted) return;
setState(() {
_checking = false;
_error = '状态读取失败,请重试';
});
}
}
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
return;
}
_recentsProtectionNoticeChecked = true;
final prefs = await SharedPreferences.getInstance();
const key = 'originos_recents_protection_notice_v1';
if (prefs.getBool(key) == true || !mounted) return;
await prefs.setBool(key, true);
if (!mounted) return;
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
}
Future<void> _monitorAccessibilityConnection({
bool ensureKeepAlive = false,
}) async {
if (_connectionCheckInFlight) return;
_connectionCheckInFlight = true;
if (mounted) setState(() {});
try {
if (ensureKeepAlive) {
_keepAliveStarting = true;
if (mounted) setState(() {});
await ScreenshotChannel.ensureRecognitionKeepAlive();
}
await ScreenshotChannel.waitForAccessibilityConnection();
final status = await ScreenshotChannel.recognitionStatus();
if (!mounted) return;
setState(() {
_status = status;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() => _error = '连接状态复核失败,请重试');
} finally {
_connectionCheckInFlight = false;
_keepAliveStarting = false;
if (mounted) setState(() {});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
if (!status.ocrDiagnosticPreview || expiresAt == null) return;
final remaining = expiresAt.difference(DateTime.now());
if (remaining <= Duration.zero) {
unawaited(_check());
return;
}
_previewExpiryTimer = Timer(remaining, _check);
}
bool _hasSystemAuthorization(String key, RecognitionStatus status) {
return switch (key) {
'notification_events' => status.notificationAuthorized,
'accessibility_events' ||
'ai_screenshot' => status.accessibilityAuthorized,
_ => true,
};
}
Future<void> _toggle(String key, bool enabled) async {
if (_authorizing) return;
if (key == 'ocr_diagnostic_preview') {
final changed = await ScreenshotChannel.setRecognitionToggle(
key,
enabled,
);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
} else if (enabled) {
_showMessage('脱敏预览已开启,将在 10 分钟后自动关闭');
}
await _check();
return;
}
if (enabled &&
key == 'accessibility_events' &&
!await _confirmLocalOcrConsent()) {
return;
}
if (enabled && key == 'ai_screenshot' && !await _confirmAiBatchConsent()) {
return;
}
if (!enabled) {
_pendingAuthorizationKey = null;
final changed = await ScreenshotChannel.setRecognitionToggle(key, false);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
}
await _check();
return;
}
final status = _status ?? await ScreenshotChannel.recognitionStatus();
if (!_hasSystemAuthorization(key, status)) {
await ScreenshotChannel.setRecognitionToggle(key, false);
_pendingAuthorizationKey = key;
await _check();
if (!mounted) return;
if (key == 'notification_events') {
await ScreenshotChannel.openNotificationAccessSettings();
} else {
await ScreenshotChannel.openAccessibilitySettings();
}
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check();
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
if (!_hasSystemAuthorization(key, status)) {
_pendingAuthorizationKey = null;
await ScreenshotChannel.setRecognitionToggle(key, false);
await _check();
if (mounted) _showMessage('未完成系统授权,识别开关仍保持关闭');
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _enableAfterAuthorization(String key) async {
_authorizing = true;
try {
final notificationsAllowed =
await ScreenshotChannel.requestNotificationPermission();
if (!notificationsAllowed) {
await ScreenshotChannel.setRecognitionToggle(key, false);
if (mounted) {
_showMessage('未允许结果通知,识别开关仍保持关闭');
}
return;
}
final changed = await ScreenshotChannel.setRecognitionToggle(key, true);
if (!changed && mounted) {
_showMessage('设置保存失败,请重试');
}
} finally {
_pendingAuthorizationKey = null;
_authorizing = false;
await _check();
}
}
Future<bool> _confirmLocalOcrConsent() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool('local_ocr_consent_v1') == true) return true;
if (!mounted) return false;
final accepted = await showModalBottomSheet<bool>(
context: context,
useSafeArea: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
decoration: BoxDecoration(
color: sheetContext.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const JzSheetHeader(title: '启用本地视觉识别', subtitle: '请确认无障碍事件识别的截屏用途'),
const SizedBox(height: 12),
const _ConsentPoint(
icon: Icons.visibility_outlined,
text: '仅在微信、支付宝疑似支付流程结束时截取当前页面。',
),
const _ConsentPoint(
icon: Icons.memory_rounded,
text: '图片只在内存中由本地 OCR 处理,完成后立即释放。',
),
const _ConsentPoint(
icon: Icons.cloud_off_outlined,
text: '默认不会上传;只有你另行开启 AI 截图补全时才允许在线分析。',
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: JzActionButton(
label: '暂不开启',
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '同意并继续',
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
);
if (accepted == true) {
await preferences.setBool('local_ocr_consent_v1', true);
return true;
}
return false;
}
Future<bool> _confirmAiBatchConsent() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool('ai_batch_consent_v2') == true) return true;
if (!mounted) return false;
final accepted = await showModalBottomSheet<bool>(
context: context,
useSafeArea: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
decoration: BoxDecoration(
color: sheetContext.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const JzSheetHeader(
title: '启用 AI 批次对账',
subtitle: '请确认支付页截图的批量处理方式',
),
const SizedBox(height: 12),
const _ConsentPoint(
icon: Icons.schedule_rounded,
text: '支付结果会按 30 秒空闲窗口归为一批,最长等待 2 分钟或累计 10 条。',
),
const _ConsentPoint(
icon: Icons.auto_fix_high_rounded,
text: '本批截图和本地候选会发送给 AI,仅允许在当前批次内保留、修正、补全或剔除。',
),
const _ConsentPoint(
icon: Icons.enhanced_encryption_outlined,
text: '待提交图片仅在本机临时加密保存,批次完成或回退后立即删除,不进入最近对账记录。',
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: JzActionButton(
label: '暂不开启',
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '同意并开启',
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
);
if (accepted == true) {
await preferences.setBool('ai_batch_consent_v2', true);
return true;
}
return false;
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final palette = context.jz;
final status = _status;
final aiAvailable =
SessionStore.instance.isAccount && SessionStore.instance.aiEnabled;
return Scaffold(
appBar: AppBar(title: const Text('智能识别')),
body: RefreshIndicator(
onRefresh: _check,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: palette.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: palette.primaryBackground,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.auto_awesome_motion_rounded,
color: AppTheme.primary,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'微信、支付宝账单自动识别',
style: TextStyle(
color: palette.text,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'两路结果会先在本机合并去重。金额、方向和来源明确时自动入账,其他情况只通知你确认。',
style: TextStyle(
color: palette.text2,
fontSize: 12,
height: 1.55,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
if (_checking)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
_ErrorCard(message: _error!, onRetry: _check)
else ...[
_RecognitionCard(
icon: Icons.accessibility_new_rounded,
title: '无障碍事件识别',
description:
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel:
_connectionCheckInFlight && !status.accessibilityConnected
? '正在连接'
: status.accessibilityConnectionLabel,
recoveryMessage: status.accessibilityNeedsRecovery
? status.taskCleanerRecoveryNeeded
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
: (status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery
? _keepAliveStarting
? '正在启动后台保护…'
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
: null,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
settingsLabel: '前往无障碍设置',
onRetry: status.accessibilityNeedsRecovery
? () => _monitorAccessibilityConnection()
: null,
onStartKeepAlive:
(status.accessibilityEvents || status.aiScreenshot) &&
status.keepAliveNeedsRecovery &&
!_keepAliveStarting
? () =>
_monitorAccessibilityConnection(ensureKeepAlive: true)
: null,
child: Column(
children: [
JzSwitchTile(
value: status.accessibilityEvents,
title: '识别开关',
subtitle: !status.accessibilityAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '识别已开启,还需允许记之显示结果通知'
: '系统权限已授权',
onChanged: (value) =>
_toggle('accessibility_events', value),
),
Divider(height: 1, color: context.jz.line),
JzSwitchTile(
value: status.ocrDiagnosticPreview,
title: 'OCR 诊断预览',
subtitle: '仅保留脱敏文字,10 分钟后自动关闭,不上传、不保存原图',
onChanged: status.accessibilityEvents
? (value) => _toggle('ocr_diagnostic_preview', value)
: null,
),
],
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.notifications_active_outlined,
title: '通知识别',
description: '只处理开启后新产生的微信、支付宝通知,不读取通知历史;关闭识别不会撤销系统授权。',
authorized: status.notificationAuthorized,
connected: status.notificationConnected,
onOpenSettings:
ScreenshotChannel.openNotificationAccessSettings,
child: JzSwitchTile(
value: status.notificationEvents,
title: '识别开关',
subtitle: !status.notificationAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '已允许读取,还需允许记之显示结果通知'
: '通知访问权限已授权',
onChanged: (value) => _toggle('notification_events', value),
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.document_scanner_outlined,
title: 'AI 截图补全',
description: '支付结果按 30 秒空闲窗口批量提交,AI 只在本批次内纠错、补全或剔除。处理结束立即删除图片。',
authorized: aiAvailable,
connected: aiAvailable && status.accessibilityConnected,
statusLabel: !aiAvailable ? 'AI 不可用' : null,
onOpenSettings: status.accessibilityAuthorized
? null
: ScreenshotChannel.openAccessibilitySettings,
child: Column(
children: [
JzSwitchTile(
value: status.aiScreenshot,
title: '批次对账开关',
subtitle: !aiAvailable
? '需要登录且账号具备 AI 权限'
: !status.accessibilityAuthorized
? '需要先授权无障碍截屏能力'
: '默认关闭,仅在支付应用前台运行',
onChanged: !aiAvailable
? null
: (value) => _toggle('ai_screenshot', value),
),
Divider(height: 1, color: context.jz.line),
InkWell(
onTap: () => context.push('/recognition-batches'),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 13,
),
child: Row(
children: [
Icon(Icons.fact_check_outlined, size: 20),
SizedBox(width: 10),
Expanded(child: Text('识别记录')),
Icon(Icons.chevron_right_rounded),
],
),
),
),
],
),
),
if (status.latestDiagnostic != null) ...[
const SizedBox(height: 10),
_RecognitionDiagnosticCard(
diagnostic: status.latestDiagnostic!,
onClear: () async {
await ScreenshotChannel.clearRecognitionDiagnostic();
await _check();
},
),
],
if (status.accessibilityEvents ||
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(
status: status,
starting: _keepAliveStarting,
onStart: () =>
_monitorAccessibilityConnection(ensureKeepAlive: true),
),
],
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.grid_view_rounded,
title: '快捷磁贴截屏',
description:
'无障碍已连接时静默截屏;未连接时每次弹出 Android 一次性投屏授权。两种方式都不会占用音量键。',
authorized: status.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnected ? '免授权截屏' : '每次系统授权',
child: SizedBox(
width: double.infinity,
child: JzActionButton(
label: '添加截屏记账磁贴',
secondary: true,
icon: const Icon(Icons.tune_rounded, size: 18),
onPressed: ScreenshotChannel.openQuickSettings,
),
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: palette.warningBackground,
borderRadius: BorderRadius.circular(14),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.shield_outlined,
color: AppTheme.orange,
size: 20,
),
const SizedBox(width: 9),
Expanded(
child: Text(
'银行密码页等安全窗口由系统禁止截屏,记之不会绕过限制。本地 OCR 图片仅在内存中处理;开启 AI 批次对账后,支付结果页会临时加密并按批上传,处理结束立即删除。',
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.55,
),
),
),
],
),
),
],
),
),
);
}
}
class _ConsentPoint extends StatelessWidget {
final IconData icon;
final String text;
const _ConsentPoint({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(color: context.jz.text2, height: 1.5),
),
),
],
),
);
}
}
class _RecognitionDiagnosticCard extends StatelessWidget {
final RecognitionDiagnostic diagnostic;
final VoidCallback onClear;
const _RecognitionDiagnosticCard({
required this.diagnostic,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final civil = ShanghaiTime.toCivil(diagnostic.at.toUtc());
final time =
'${civil.month.toString().padLeft(2, '0')}-'
'${civil.day.toString().padLeft(2, '0')} '
'${civil.hour.toString().padLeft(2, '0')}:'
'${civil.minute.toString().padLeft(2, '0')}';
final display = RecognitionDiagnosticDisplay.from(diagnostic);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.fact_check_outlined, color: AppTheme.primary),
const SizedBox(width: 9),
Expanded(
child: Text(
'最近一次识别',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w800,
),
),
),
Semantics(
button: true,
label: '清除识别诊断',
child: InkWell(
onTap: onClear,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 7,
),
child: Text(
'清除',
style: TextStyle(
color: AppTheme.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
const SizedBox(height: 8),
Text(
'${diagnostic.appName} · ${display.stageLabel} · ${display.summaryLabel}',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
display.reasonLabel,
style: TextStyle(color: context.jz.text2, height: 1.45),
),
if (display.recognitionKindLabel != null ||
display.amountSourceLabel != null ||
display.statusStrengthLabel != null ||
diagnostic.expectedAmountMatched != null ||
diagnostic.resultTransitionObserved != null) ...[
const SizedBox(height: 9),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
_EvidencePill(
label: display.resultLabel,
positive:
diagnostic.result == 'matched' ||
diagnostic.result == 'auto_ready',
),
if (display.recognitionKindLabel != null)
_EvidencePill(
label: display.recognitionKindLabel!,
positive: true,
),
if (display.amountSourceLabel != null)
_EvidencePill(
label: display.amountSourceLabel!,
positive: true,
),
if (diagnostic.resultFingerprint != null)
_EvidencePill(
label: '结果指纹 ${diagnostic.resultFingerprint}',
positive: true,
),
if (display.statusStrengthLabel != null)
_EvidencePill(
label: display.statusStrengthLabel!,
positive: display.statusStrengthPositive,
),
if (diagnostic.expectedAmountMatched != null)
_EvidencePill(
label: diagnostic.expectedAmountMatched! ? '金额匹配' : '金额不匹配',
positive: diagnostic.expectedAmountMatched!,
),
if (diagnostic.resultTransitionObserved != null)
_EvidencePill(
label: diagnostic.resultTransitionObserved!
? '已观察到页面跳转'
: '未观察到页面跳转',
positive: diagnostic.resultTransitionObserved!,
),
],
),
],
if (diagnostic.ocrPreview.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OCR 脱敏预览',
style: TextStyle(
color: context.jz.text,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
...diagnostic.ocrPreview.map(
(line) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
line,
style: TextStyle(
color: context.jz.text2,
fontSize: 12,
height: 1.35,
),
),
),
),
],
),
),
],
const SizedBox(height: 7),
Text(
'$time · 节点 ${diagnostic.nodeCount}'
'${diagnostic.ocrMs == null ? '' : ' · OCR ${diagnostic.ocrMs}ms'}'
'${diagnostic.amountCandidates == 0 ? '' : ' · 金额候选 ${diagnostic.amountCandidates}'}',
style: TextStyle(color: context.jz.text3, fontSize: 11),
),
],
),
);
}
}
class _EvidencePill extends StatelessWidget {
final String label;
final bool positive;
const _EvidencePill({required this.label, required this.positive});
@override
Widget build(BuildContext context) {
final color = positive ? AppTheme.primary : context.jz.text3;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: positive ? context.jz.primaryBackground : context.jz.background,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: positive ? AppTheme.primary : context.jz.line,
),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
final bool starting;
final VoidCallback onStart;
const _BackgroundKeepAliveCard({
required this.status,
required this.starting,
required this.onStart,
});
@override
Widget build(BuildContext context) {
final isVivo = status.manufacturer.toLowerCase().contains('vivo');
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.battery_saver_outlined,
color: AppTheme.orange,
size: 21,
),
),
const SizedBox(width: 11),
const Expanded(
child: Text(
'后台识别保活',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
Text(
status.recentsProtectionActive
? '任务清理防护中'
: status.keepAliveRunning
? '保护运行中'
: status.keepAliveExpected
? '保护未运行'
: status.batteryOptimizationIgnored
? '后台限制较少'
: '需要设置',
style: TextStyle(
color:
status.keepAliveRunning || status.recentsProtectionActive
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
Text(
status.recentsProtectionActive
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
if (status.keepAliveNeedsRecovery) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: JzActionButton(
label: starting ? '正在启动…' : '启动后台保护',
secondary: true,
icon: const Icon(Icons.shield_outlined, size: 18),
onPressed: starting ? null : onStart,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '电池优化',
secondary: true,
onPressed: ScreenshotChannel.openBatteryOptimizationSettings,
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: isVivo ? '自启动管理' : '后台设置',
secondary: true,
onPressed: ScreenshotChannel.openBackgroundStartupSettings,
),
),
],
),
],
),
);
}
}
class _RecognitionCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final bool authorized;
final bool connected;
final String? statusLabel;
final Widget child;
final VoidCallback? onOpenSettings;
final VoidCallback? onRetry;
final VoidCallback? onStartKeepAlive;
final String? recoveryMessage;
final String settingsLabel;
const _RecognitionCard({
required this.icon,
required this.title,
required this.description,
required this.authorized,
required this.connected,
required this.child,
this.statusLabel,
this.onOpenSettings,
this.onRetry,
this.onStartKeepAlive,
this.recoveryMessage,
this.settingsLabel = '前往系统设置',
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.orange;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: palette.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
),
const SizedBox(width: 11),
Expanded(
child: Text(
title,
style: TextStyle(
color: palette.text,
fontWeight: FontWeight.w800,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.warningBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusLabel ??
(connected
? '已连接'
: authorized
? '等待连接'
: '未授权'),
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
Text(
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
if (recoveryMessage != null) ...[
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline_rounded,
color: AppTheme.orange,
size: 18,
),
const SizedBox(width: 7),
Expanded(
child: Text(
recoveryMessage!,
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.5,
),
),
),
],
),
],
const SizedBox(height: 6),
child,
if (onOpenSettings != null ||
onRetry != null ||
onStartKeepAlive != null) ...[
const SizedBox(height: 6),
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 (onStartKeepAlive != null)
TextButton.icon(
onPressed: onStartKeepAlive,
icon: const Icon(Icons.shield_outlined, size: 18),
label: const Text('启动后台保护'),
),
if (onOpenSettings != null)
TextButton.icon(
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_outlined, size: 18),
label: Text(settingsLabel),
),
],
),
],
],
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorCard({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final palette = context.jz;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.line),
),
child: Column(
children: [
Text(message, style: TextStyle(color: palette.text2)),
const SizedBox(height: 10),
JzActionButton(label: '重新读取', secondary: true, onPressed: onRetry),
],
),
);
}
}