diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 216b729db..0d1e8f6e1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -26,13 +26,20 @@
+
+
-
+
+
+
+
+
-
@@ -132,6 +138,48 @@
android:resource="@xml/beecount_widget_info" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+ 摇一摇自动记账
+ 摇动手机自动识别当前屏幕内容并记账
diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 000000000..c6404dd74
--- /dev/null
+++ b/android/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/lib/main.dart b/lib/main.dart
index e52d3d512..bce017f30 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -20,6 +20,7 @@ import 'providers/security_providers.dart';
import 'services/system/reminder_monitor_service.dart';
import 'providers/credit_card_reminder_providers.dart';
import 'services/platform/screenshot_monitor_service.dart';
+import 'services/platform/shake_billing_service.dart';
import 'services/platform/image_share_handler_service.dart';
import 'services/platform/app_link_service.dart';
import 'services/system/logger_service.dart';
@@ -115,6 +116,9 @@ Future main() async {
// 恢复截图自动识别设置(Android专属),传入container
await _restoreScreenshotMonitor(container);
+ // 恢复摇一摇自动记账状态(Android专属)
+ await _restoreShakeBilling(container);
+
// 初始化图片分享处理服务(Android专属)
if (Platform.isAndroid) {
_setupImageShareHandler(container);
@@ -140,11 +144,23 @@ Future main() async {
// 执行,失败不致命。
unawaited(_runOrphanFileGcOnce(container));
- runApp(ProviderScope(
- parent: container,
- observers: const [_WidgetUpdateObserver()],
- child: const MainApp(),
- ));
+ // 全局错误捕获 — 未捕获的 Flutter/Dart 异常
+ FlutterError.onError = (FlutterErrorDetails details) {
+ logger.error('FlutterError', '未捕获的 Flutter 错误', details.exception.toString(), details.stack);
+ };
+
+ runZonedGuarded(
+ () {
+ runApp(ProviderScope(
+ parent: container,
+ observers: const [_WidgetUpdateObserver()],
+ child: const MainApp(),
+ ));
+ },
+ (Object error, StackTrace stack) {
+ logger.error('ZONE', '未捕获的区域错误', error, stack);
+ },
+ );
}
/// Provider observer to update widget on app start
@@ -256,7 +272,31 @@ Future _restoreScreenshotMonitor(ProviderContainer container) async {
}
} catch (e) {
print('❌ 恢复截图监听失败: $e');
- // 不抛出异常,避免影响应用启动
+ }
+
+}
+
+/// 恢复摇一摇自动记账状态(仅Android)
+///
+/// 应用崩溃/重启后 Dart 内存状态丢失,从 SharedPreferences 恢复。
+Future _restoreShakeBilling(ProviderContainer container) async {
+ if (!Platform.isAndroid) return;
+
+ try {
+ print('📳 检查并恢复摇一摇自动记账...');
+ final shakeBilling = ShakeBillingService(container);
+ final shakeEnabled = await shakeBilling.isShakeBillingEnabled();
+
+ if (shakeEnabled) {
+ print('✅ 发现用户已启用摇一摇自动记账');
+ print('🔄 正在重新启用摇一摇检测...');
+ await shakeBilling.enableShakeBilling();
+ print('✅ 摇一摇自动记账已成功恢复');
+ } else {
+ print('ℹ️ 用户未启用摇一摇自动记账,跳过恢复');
+ }
+ } catch (e) {
+ print('📳 恢复摇一摇自动记账失败: $e');
}
}
diff --git a/lib/pages/automation/shake_billing_page.dart b/lib/pages/automation/shake_billing_page.dart
new file mode 100644
index 000000000..07f1e3d75
--- /dev/null
+++ b/lib/pages/automation/shake_billing_page.dart
@@ -0,0 +1,789 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../widgets/ui/primary_header.dart';
+import '../../widgets/ui/toast.dart';
+
+import '../../providers.dart';
+import '../../services/platform/shake_billing_service.dart';
+import '../../l10n/app_localizations.dart';
+
+/// 摇一摇自动记账设置页面
+class ShakeBillingPage extends ConsumerStatefulWidget {
+ const ShakeBillingPage({super.key});
+
+ @override
+ ConsumerState createState() => _ShakeBillingPageState();
+}
+
+class _ShakeBillingPageState extends ConsumerState with WidgetsBindingObserver {
+ late final ShakeBillingService _shakeBilling;
+ bool _isShakeBillingEnabled = false;
+ bool _isAccessibilityServiceEnabled = false;
+ bool _isLoading = true;
+ bool _isInitialized = false;
+
+ /// 保活综合状态(由 Kotlin 端返回)
+ Map _keepAliveStatus = {};
+
+ /// 摇一摇记账结果通知渠道信息
+ Map _shakeResultChannelInfo = {};
+
+ /// 通知渠道是否满足横幅弹窗条件
+ bool get _notifChannelOk =>
+ _shakeResultChannelInfo['isEnabled'] == true &&
+ (_shakeResultChannelInfo['importance'] == 'high' ||
+ _shakeResultChannelInfo['importance'] == 'max');
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInitialized) {
+ final container = ProviderScope.containerOf(context);
+ _shakeBilling = ShakeBillingService(container);
+ _loadMonitorStatus();
+ _isInitialized = true;
+ }
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ super.didChangeAppLifecycleState(state);
+ if (state == AppLifecycleState.resumed) {
+ _loadMonitorStatus();
+ }
+ }
+
+ Future _loadMonitorStatus() async {
+ final shakeEnabled = await _shakeBilling.isShakeBillingEnabled();
+
+ bool accessibilityEnabled = false;
+ try {
+ accessibilityEnabled = await _shakeBilling.checkAccessibilityServiceEnabled();
+ } catch (e) {
+ print('检查无障碍服务状态失败: $e');
+ }
+
+ Map keepAliveStatus = {};
+ try {
+ keepAliveStatus = await _shakeBilling.getKeepAliveStatus();
+ } catch (e) {
+ print('查询保活状态失败: $e');
+ }
+
+ // 查询摇一摇记账结果通知渠道信息
+ Map channelInfo = {};
+ try {
+ channelInfo = await _shakeBilling.getNotificationChannelInfo('shake_result');
+ print('📢 通知渠道 shake_result: $channelInfo');
+ } catch (e) {
+ print('查询通知渠道信息失败: $e');
+ }
+
+ setState(() {
+ _isShakeBillingEnabled = shakeEnabled;
+ _isAccessibilityServiceEnabled = accessibilityEnabled;
+ _keepAliveStatus = keepAliveStatus;
+ _shakeResultChannelInfo = channelInfo;
+ _isLoading = false;
+ });
+ }
+
+ Future _toggleShakeBilling(bool value) async {
+ final l10n = AppLocalizations.of(context);
+
+ try {
+ if (value) {
+ await _shakeBilling.enableShakeBilling();
+ setState(() => _isShakeBillingEnabled = true);
+ if (mounted) showToast(context, l10n.enableSuccess);
+
+ final enabled = await _shakeBilling.checkAccessibilityServiceEnabled();
+ setState(() => _isAccessibilityServiceEnabled = enabled);
+
+ _loadMonitorStatus();
+
+ if (!enabled && mounted) {
+ showToast(context, '请前往系统设置 - 无障碍 - 已安装的应用 - 蜜蜂记账,开启摇一摇自动记账服务', duration: const Duration(seconds: 4));
+ }
+ } else {
+ await _shakeBilling.disableShakeBilling();
+ setState(() => _isShakeBillingEnabled = false);
+ _loadMonitorStatus();
+ if (mounted) showToast(context, l10n.disableSuccess);
+ }
+ } catch (e) {
+ if (mounted) {
+ showToast(context, '${l10n.enableFailed}: $e', duration: const Duration(seconds: 3));
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final primaryColor = ref.watch(primaryColorProvider);
+ final l10n = AppLocalizations.of(context);
+
+ return Scaffold(
+ backgroundColor: theme.colorScheme.surface,
+ body: Column(
+ children: [
+ PrimaryHeader(
+ title: '摇一摇自动记账',
+ showBack: true,
+ leadingIcon: Icons.sensors,
+ leadingPlain: true,
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ // ── 功能介绍 ──
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.info_outline, color: primaryColor, size: 24),
+ const SizedBox(width: 8),
+ Text(
+ '功能介绍',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ '摇动手机,通过无障碍服务截取当前屏幕,自动识别账单信息并完成记账。\n\n'
+ '• 摇动手机即可触发自动记账\n'
+ '• 所有版本均可用(包括 Google Play 版)\n'
+ '• 需开启无障碍服务权限',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
+ height: 1.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // ── 摇动示例图 ──
+ _ShakeGuideIllustration(),
+
+ const SizedBox(height: 20),
+
+ // ── 摇一摇自动记账开关 ──
+ Padding(
+ padding: const EdgeInsets.only(left: 4, bottom: 8),
+ child: Text(
+ '摇一摇自动记账',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: primaryColor,
+ ),
+ ),
+ ),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: primaryColor.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(Icons.sensors, color: primaryColor, size: 28),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '摇一摇自动记账',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ _isShakeBillingEnabled
+ ? (_isAccessibilityServiceEnabled ? '已开启' : '已开启(无障碍未授权)')
+ : '已关闭',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: _isShakeBillingEnabled
+ ? primaryColor
+ : theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Switch(
+ value: _isShakeBillingEnabled,
+ onChanged: _isLoading ? null : _toggleShakeBilling,
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // 无障碍服务引导卡片
+ if (_isShakeBillingEnabled && !_isAccessibilityServiceEnabled) ...[
+ const SizedBox(height: 12),
+ Card(
+ color: Colors.orange.withValues(alpha: 0.08),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.accessibility_new, color: Colors.orange, size: 24),
+ const SizedBox(width: 8),
+ Text(
+ '需要无障碍服务权限',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ '启用摇一摇自动记账需要在系统无障碍设置中开启「蜜蜂记账」服务。\n\n'
+ '操作步骤:\n'
+ '1. 点击下方按钮打开无障碍设置\n'
+ '2. 找到「已安装的应用」或「已安装服务」\n'
+ '3. 点击「蜜蜂记账」\n'
+ '4. 打开服务开关并确认启用',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
+ height: 1.5,
+ ),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton.icon(
+ onPressed: () async {
+ await _shakeBilling.openAccessibilitySettings();
+ await Future.delayed(const Duration(seconds: 1));
+ if (mounted) {
+ final enabled = await _shakeBilling.checkAccessibilityServiceEnabled();
+ setState(() => _isAccessibilityServiceEnabled = enabled);
+ }
+ },
+ icon: const Icon(Icons.settings),
+ label: const Text('打开无障碍设置'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+
+ const SizedBox(height: 24),
+
+ // ── 后台保活状态 ──
+ Padding(
+ padding: const EdgeInsets.only(left: 4, bottom: 8),
+ child: Text(
+ '后台保活',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: primaryColor,
+ ),
+ ),
+ ),
+ if (_isShakeBillingEnabled)
+ _buildKeepAliveStatusCard(context, primaryColor, l10n)
+ else
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: Colors.grey.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(Icons.shield_outlined, color: Colors.grey, size: 28),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '保活服务已关闭',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '开启摇一摇自动记账后自动启用保活,无需手动操作',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ height: 1.4,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildKeepAliveStatusCard(
+ BuildContext context,
+ Color primaryColor,
+ AppLocalizations l10n,
+ ) {
+ final theme = Theme.of(context);
+ final batteryOpt = _keepAliveStatus['isIgnoringBatteryOptimizations'] == true;
+ final accessibilityRunning = _keepAliveStatus['accessibilityServiceRunning'] == true;
+ final keepAliveRunning = _keepAliveStatus['keepAliveServiceRunning'] == true;
+
+ final importance = _shakeResultChannelInfo['importance'] as String? ?? '';
+ final notifChannelDescription = _notifChannelOk
+ ? '渠道重要性 $importance,可弹出横幅通知'
+ : '重要性 $importance,需设为高才能弹出横幅通知';
+
+ final healthItems = <_HealthItem>[
+ _HealthItem(
+ icon: Icons.accessible,
+ label: '无障碍服务',
+ description: '系统级权限,后台常驻不被系统杀死',
+ ok: accessibilityRunning,
+ okText: '运行中',
+ failText: '未运行',
+ ),
+ _HealthItem(
+ icon: Icons.battery_std,
+ label: '电池优化',
+ description: '豁免后系统不会在后台限制应用活动',
+ ok: batteryOpt,
+ okText: '已豁免',
+ failText: '未优化',
+ ),
+ _HealthItem(
+ icon: Icons.shield,
+ label: '保活前台服务',
+ description: '前台通知保活,关闭摇一摇后自动停止',
+ ok: keepAliveRunning,
+ okText: '运行中',
+ failText: '未运行',
+ ),
+ _HealthItem(
+ icon: Icons.notifications_active,
+ label: '记账结果横幅',
+ description: notifChannelDescription,
+ ok: _notifChannelOk,
+ okText: '已开启',
+ failText: '需设置',
+ ),
+ ];
+
+ final okCount = healthItems.where((e) => e.ok).length;
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: (okCount == healthItems.length ? Colors.green : Colors.orange).withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ okCount == healthItems.length ? Icons.shield : Icons.shield_outlined,
+ color: okCount == healthItems.length ? Colors.green : Colors.orange,
+ size: 28,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '保活健康度 $okCount/${healthItems.length}',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ okCount == healthItems.length
+ ? '所有保活机制运行正常'
+ : '部分保活项未就绪,建议优化',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: okCount == healthItems.length ? Colors.green : Colors.orange,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ // Health status list
+ ...healthItems.map((item) => Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ children: [
+ Icon(item.icon, size: 20, color: item.ok ? Colors.green : Colors.grey),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(item.label, style: theme.textTheme.bodyMedium),
+ Text(
+ item.description,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: (item.ok ? Colors.green : Colors.grey).withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ item.ok ? item.okText : item.failText,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: item.ok ? Colors.green : Colors.grey,
+ fontSize: 11,
+ ),
+ ),
+ ),
+ ],
+ ),
+ )),
+ // 通知横幅设置引导
+ if (!_notifChannelOk) ...[
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: () async {
+ await _shakeBilling.openNotificationChannelSettings('shake_result');
+ await Future.delayed(const Duration(seconds: 2));
+ _loadMonitorStatus();
+ },
+ icon: const Icon(Icons.notifications, size: 18),
+ label: const Text('前往通知设置,开启横幅通知'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.orange,
+ side: const BorderSide(color: Colors.orange),
+ ),
+ ),
+ ),
+ const SizedBox(height: 4),
+ Padding(
+ padding: const EdgeInsets.only(left: 4),
+ child: Text(
+ '部分手机默认关闭横幅通知,需手动开启。请进入「通知」→「摇一摇记账结果」→ 打开「通知横幅/悬浮通知」',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: Colors.orange[700],
+ fontSize: 11,
+ height: 1.4,
+ ),
+ ),
+ ),
+ ],
+ // Action buttons for non-optimized items
+ if (!batteryOpt) ...[
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: () async {
+ await _shakeBilling.openBatteryOptimizationSettings();
+ },
+ icon: const Icon(Icons.battery_std, size: 18),
+ label: const Text('前往电池优化设置'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.orange,
+ side: const BorderSide(color: Colors.orange),
+ ),
+ ),
+ ),
+ ],
+ if (!accessibilityRunning) ...[
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: () async {
+ await _shakeBilling.openAccessibilitySettings();
+ },
+ icon: const Icon(Icons.accessibility_new, size: 18),
+ label: const Text('前往无障碍设置'),
+ ),
+ ),
+ ],
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: () async {
+ final opened = await _shakeBilling.openAutoStartSettings();
+ if (!opened && context.mounted) {
+ _showAutoStartGuideDialog(context);
+ }
+ },
+ icon: const Icon(Icons.power_settings_new, size: 18),
+ label: const Text('自启动管理'),
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '无法自动检测自启动状态,不同手机路径不同:设置 → 应用管理 → BeeCount → 自启动',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
+ fontSize: 10,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.blue.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(Icons.info_outline, color: Colors.blue, size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ '保活前台服务会在通知栏显示一条静默通知,保持应用在后台不被系统清理。开启摇一摇自动记账后自动启用,关闭后自动停止,无需手动操作。',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: Colors.blue[700],
+ height: 1.4,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// 自启动引导弹窗
+Future _showAutoStartGuideDialog(BuildContext context) async {
+ await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('开启自启动'),
+ content: const Text(
+ '当前设备不支持自动跳转,请手动操作:\n\n'
+ '1. 打开手机「设置」\n'
+ '2. 进入「应用管理」或「应用设置」\n'
+ '3. 找到「BeeCount」\n'
+ '4. 开启「自启动」或「允许自启动」权限\n\n'
+ '开启后保活服务更稳定,不会被系统后台清理。',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: const Text('知道了'),
+ ),
+ ],
+ ),
+ );
+}
+
+/// 摇动手机示例图 — 带动画提示如何摇手机
+class _ShakeGuideIllustration extends StatefulWidget {
+ @override
+ State<_ShakeGuideIllustration> createState() => _ShakeGuideIllustrationState();
+}
+
+class _ShakeGuideIllustrationState extends State<_ShakeGuideIllustration>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _ctrl;
+ late final Animation _shakeAnim;
+
+ @override
+ void initState() {
+ super.initState();
+ _ctrl = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 600),
+ )..repeat(reverse: true);
+ _shakeAnim = Tween(begin: -16.0, end: 16.0).animate(
+ CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
+ );
+ }
+
+ @override
+ void dispose() {
+ _ctrl.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final primaryColor = theme.colorScheme.primary;
+
+ // 运动轨迹点 — 显示在手机左右两侧的短线
+ Widget _trailDots({required bool left}) {
+ return AnimatedBuilder(
+ animation: _shakeAnim,
+ builder: (_, __) {
+ final progress = (_shakeAnim.value + 16) / 32; // 0→1
+ final opacity = left ? (1 - progress) : progress;
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: List.generate(3, (i) {
+ final dotOpacity = ((left ? (2 - i) : i) / 2.0 * opacity)
+ .clamp(0.0, 0.7);
+ return Container(
+ width: 4,
+ height: 4,
+ margin: const EdgeInsets.symmetric(horizontal: 2),
+ decoration: BoxDecoration(
+ color: primaryColor.withValues(alpha: dotOpacity),
+ shape: BoxShape.circle,
+ ),
+ );
+ }),
+ );
+ },
+ );
+ }
+
+ return Card(
+ elevation: 0,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ side: BorderSide(color: primaryColor.withValues(alpha: 0.15)),
+ ),
+ color: primaryColor.withValues(alpha: 0.04),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 动画手机 + 轨迹点
+ SizedBox(
+ height: 80,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ // 左侧轨迹点
+ Positioned(left: 0, child: _trailDots(left: true)),
+ // 右侧轨迹点
+ Positioned(right: 0, child: _trailDots(left: false)),
+ // 手机图标 — 左右平移 + 轻微摆头模拟手腕弧线
+ AnimatedBuilder(
+ animation: _shakeAnim,
+ builder: (_, __) => Transform.translate(
+ offset: Offset(_shakeAnim.value, 0),
+ child: Transform.rotate(
+ angle: _shakeAnim.value * 0.012,
+ child: Icon(Icons.phone_android,
+ color: primaryColor, size: 48),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ '握住手机两侧,以手腕为轴快速摇动 5~6 次',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// 保活健康项数据模型
+class _HealthItem {
+ final IconData icon;
+ final String label;
+
+ /// 简短说明(如"前台通知保活,关闭摇一摇后自动停止")
+ final String description;
+
+ final bool ok;
+ final String okText;
+ final String failText;
+
+ _HealthItem({
+ required this.icon,
+ required this.label,
+ required this.description,
+ required this.ok,
+ required this.okText,
+ required this.failText,
+ });
+}
diff --git a/lib/pages/main/mine_page.dart b/lib/pages/main/mine_page.dart
index bf0655718..fa48f4ef0 100644
--- a/lib/pages/main/mine_page.dart
+++ b/lib/pages/main/mine_page.dart
@@ -27,7 +27,6 @@ import '../transaction/recurring_transaction_page.dart';
import '../settings/reminder_settings_page.dart';
import '../settings/language_settings_page.dart';
import '../settings/widget_management_page.dart';
-import '../automation/auto_billing_settings_page.dart';
import '../ai/ai_settings_page.dart';
import '../cloud/cloud_sync_page.dart';
import '../cloud/beecount_cloud_sync_page.dart';
diff --git a/lib/pages/settings/smart_billing_page.dart b/lib/pages/settings/smart_billing_page.dart
index fb2155e69..532ce5853 100644
--- a/lib/pages/settings/smart_billing_page.dart
+++ b/lib/pages/settings/smart_billing_page.dart
@@ -9,6 +9,7 @@ import '../../providers/smart_billing_providers.dart';
import '../../providers/theme_providers.dart';
import '../ai/ai_settings_page.dart';
import '../automation/auto_billing_settings_page.dart';
+import '../automation/shake_billing_page.dart';
import 'shortcuts_guide_page.dart';
import '../../l10n/app_localizations.dart';
@@ -264,6 +265,20 @@ class SmartBillingPage extends ConsumerWidget {
),
BeeTokens.cardDivider(context),
],
+ // 摇一摇自动记账(Android)
+ if (Platform.isAndroid && !_isGooglePlayBuild) ...[
+ AppListTile(
+ leading: Icons.sensors,
+ title: '摇一摇自动记账',
+ subtitle: '摇动手机,无障碍截屏并记账',
+ onTap: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const ShakeBillingPage()),
+ );
+ },
+ ),
+ BeeTokens.cardDivider(context),
+ ],
// 快捷指令
AppListTile(
leading: Icons.app_shortcut,
diff --git a/lib/providers/keep_alive_provider.dart b/lib/providers/keep_alive_provider.dart
new file mode 100644
index 000000000..bb4e5cf20
--- /dev/null
+++ b/lib/providers/keep_alive_provider.dart
@@ -0,0 +1,17 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// 保活开关(默认关闭)
+final keepAliveProvider = StateProvider((ref) => false);
+
+/// 保活开关持久化初始化
+final keepAliveInitProvider = FutureProvider((ref) async {
+ final prefs = await SharedPreferences.getInstance();
+ final saved = prefs.getBool('keep_alive_enabled');
+ if (saved != null) {
+ ref.read(keepAliveProvider.notifier).state = saved;
+ }
+ ref.listen(keepAliveProvider, (prev, next) async {
+ await prefs.setBool('keep_alive_enabled', next);
+ });
+});
diff --git a/lib/services/automation/auto_billing_service.dart b/lib/services/automation/auto_billing_service.dart
index 14af99e19..677c71599 100644
--- a/lib/services/automation/auto_billing_service.dart
+++ b/lib/services/automation/auto_billing_service.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
+import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -21,19 +22,45 @@ import 'auto_billing_config.dart';
class AutoBillingService {
static const _ledgerIdKey = 'current_ledger_id';
static const _processedScreenshotsKey = 'processed_screenshots';
+ static const _shakeBillingControlChannel =
+ MethodChannel('com.tntlikely.beecount/shake_billing_control');
final ProviderContainer _container;
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
+ /// AI 调用超时后尚未收到延迟响应的截图数
+ int _pendingTimeoutCount = 0;
+
+ /// 记录 Dart 关键阶段(闪退后仍可通过日志查看)
+ void _logStage(String stage) {
+ logger.info('DartStage', stage);
+ print('🐛 [DartStage] $stage');
+ }
+
// 防重复处理
final Set _processedPaths = {};
String? _lastProcessedPath;
int _lastProcessedTime = 0;
+ /// 兜底本地化文案(后台恢复期取不到 PlatformDispatcher.locale 时使用)
+ late final AppLocalizations _defaultL10n = lookupAppLocalizations(
+ const Locale('zh', 'CN'));
+
+ /// 安全获取本地化文案。后台 Activity 重建过渡期可能取不到 locale,兜底用默认值。
+ AppLocalizations _safeL10n() {
+ try {
+ return lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ } catch (_) {
+ return _defaultL10n;
+ }
+ }
+
AutoBillingService(this._container) {
_initNotifications();
_loadProcessedScreenshots();
+ // 恢复 Engine 挂起时未能送达的通知
+ flushPendingNotification();
}
/// 解析当前账本 ID(Provider → SharedPreferences → 数据库默认)。
@@ -110,11 +137,15 @@ class AutoBillingService {
/// 核心:处理截图并自动记账
/// [imagePath] 截图文件路径
/// [showNotification] 是否显示通知(默认true)
+ /// [showProgressNotifications] 是否显示中间进度通知(默认true)。
+ /// 摇一摇路径传 false,只保留最终结果通知横幅。
/// 返回:交易记录ID,失败返回null
Future processScreenshot(
String imagePath, {
bool showNotification = true,
+ bool showProgressNotifications = true,
}) async {
+ _logStage('process_screenshot_start');
final totalStartTime = DateTime.now().millisecondsSinceEpoch;
print('📸 [AutoBilling] 开始处理截图: $imagePath');
logger.info('AutoBilling', '开始处理截图', imagePath);
@@ -139,11 +170,13 @@ class AutoBillingService {
_lastProcessedPath = imagePath;
_lastProcessedTime = now;
- try {
- const notificationId = 1001;
- // 最终结果(成功/失败)用独立 ID,避免 iOS 把它当成对 1001 的静默更新
- const resultNotificationId = 1101;
+ const notificationId = 1001;
+ const resultNotificationId = 1101;
+ // 超时后仍需等待 AI 延迟响应,故在 try 外保留引用
+ Future? pendingAiFuture;
+
+ try {
// 检查文件是否存在
final file = File(imagePath);
@@ -153,9 +186,9 @@ class AutoBillingService {
logger.info('AutoBilling', '文件尚未就绪,开始等待',
'路径=$imagePath, 超时=${AutoBillingConfig.fileWaitTimeout}ms');
- if (showNotification) {
+ if (showNotification && showProgressNotifications) {
final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ _safeL10n();
await _showNotification(
id: notificationId,
title: l10n.autoBillingNotifyDetectedTitle,
@@ -181,11 +214,11 @@ class AutoBillingService {
logger.error('AutoBilling', '截图文件等待超时',
'路径=$imagePath, 等待时间=${waitTime}ms, 文件存在=${await file.exists()}');
if (showNotification) {
- final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ final l10n = _safeL10n();
await _showFinalNotification(
progressId: notificationId,
- finalId: resultNotificationId,
+ finalId: 1101,
+ capturePath: imagePath,
title: l10n.autoBillingNotifyFileUnavailableTitle,
body: l10n.autoBillingNotifyFileUnavailableBody,
);
@@ -204,11 +237,11 @@ class AutoBillingService {
AICapabilityType.vision)) {
logger.warning('AutoBilling', 'AI vision 未配置,跳过自动记账');
if (showNotification) {
- final l10n = lookupAppLocalizations(
- PlatformDispatcher.instance.locale);
+ final l10n = _safeL10n();
await _showFinalNotification(
progressId: notificationId,
- finalId: resultNotificationId,
+ finalId: 1101,
+ capturePath: imagePath,
title: l10n.aiNotConfiguredNotificationTitle,
body: l10n.aiNotConfiguredNotificationBody,
);
@@ -216,10 +249,10 @@ class AutoBillingService {
return null;
}
- // 更新通知:开始识别
- if (showNotification) {
+ // 更新通知:开始识别(摇一摇路径不显示进度通知)
+ if (showNotification && showProgressNotifications) {
final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ _safeL10n();
await _showNotification(
id: notificationId,
title: l10n.autoBillingNotifyRecognizingScreenshotTitle,
@@ -232,11 +265,11 @@ class AutoBillingService {
if (ledgerId == null) {
logger.error('AutoBilling', '无可用账本');
if (showNotification) {
- final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ final l10n = _safeL10n();
await _showFinalNotification(
progressId: notificationId,
- finalId: resultNotificationId,
+ finalId: 1101,
+ capturePath: imagePath,
title: l10n.autoBillingNotifyNoLedgerTitle,
body: l10n.autoBillingNotifyNoLedgerBody,
);
@@ -245,46 +278,53 @@ class AutoBillingService {
return null;
}
+ _logStage('ai_vision_start');
final aiStartTime = DateTime.now().millisecondsSinceEpoch;
logger.info('AutoBilling', '开始 AI 视觉识别 + 落库');
final autoAddAttachment =
_container.read(smartBillingAutoAttachmentProvider);
- final result = await _container.read(aiBookkeeperProvider).fromImage(
+ final autoAddAttachmentFn = autoAddAttachment
+ ? (txId, _) async {
+ try {
+ final attachmentService =
+ _container.read(attachmentServiceProvider);
+ await attachmentService.saveAttachment(
+ transactionId: txId,
+ sourceFile: file,
+ index: 0,
+ urgent: true,
+ );
+ _container
+ .read(attachmentListRefreshProvider.notifier)
+ .state++;
+ } catch (e, st) {
+ logger.error('AutoBilling', '保存截图附件失败', e, st);
+ }
+ }
+ : null;
+
+ // 分离 AI Future 引用,超时后仍需等待其延迟响应
+ pendingAiFuture = _container.read(aiBookkeeperProvider).fromImage(
image: file,
ledgerId: ledgerId,
billingTypes: const [
TagSeedService.billingTypeImage,
TagSeedService.billingTypeAi,
],
- l10n: lookupAppLocalizations(PlatformDispatcher.instance.locale),
+ l10n: _safeL10n(),
// 多笔截图(罕见,但 AI 可能识别出一张账单页里的多笔)时,每笔都挂
// 同一张原图,与相册路径行为对齐。
//
// 走 urgent 模式:跳过 FlutterImageCompress(platform channel,后台冻
// 结时会卡)和 _getImageInfo,用 sync File.copy 几十 ms 内完成。
// 这样 attachment 在 perform() return 前就写完,不依赖用户开 app。
- onSaved: autoAddAttachment
- ? (txId, _) async {
- try {
- final attachmentService =
- _container.read(attachmentServiceProvider);
- await attachmentService.saveAttachment(
- transactionId: txId,
- sourceFile: file,
- index: 0,
- urgent: true,
- );
- _container
- .read(attachmentListRefreshProvider.notifier)
- .state++;
- } catch (e, st) {
- logger.error('AutoBilling', '保存截图附件失败', e, st);
- }
- }
- : null,
+ onSaved: autoAddAttachmentFn,
);
+ final result = await pendingAiFuture!.timeout(const Duration(seconds: 15));
+
+ _logStage('ai_vision_complete');
final aiElapsed = DateTime.now().millisecondsSinceEpoch - aiStartTime;
logger.info('AutoBilling', 'AI 识别 + 落库完成',
'耗时=${aiElapsed}ms, 成功=${result.savedCount} 笔, 失败=${result.failedCount}');
@@ -294,11 +334,11 @@ class AutoBillingService {
if (!result.success) {
if (showNotification) {
- final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ final l10n = _safeL10n();
await _showFinalNotification(
progressId: notificationId,
finalId: resultNotificationId,
+ capturePath: imagePath,
title: l10n.autoBillingNotifyRecognizeFailedTitle,
body: l10n.autoBillingNotifyRecognizeFailedBody,
);
@@ -306,15 +346,18 @@ class AutoBillingService {
return null;
}
+ _logStage('post_processor_start');
_container.read(statsRefreshProvider.notifier).state++;
await PostProcessor.runC(_container, ledgerId: ledgerId, tags: true);
+ _logStage('post_processor_done');
if (showNotification) {
- final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ _logStage('show_final_notification');
+ final l10n = _safeL10n();
await _showFinalNotification(
progressId: notificationId,
finalId: resultNotificationId,
+ capturePath: imagePath,
title: _successTitle(result, l10n),
body: _successBody(result, l10n),
);
@@ -322,13 +365,48 @@ class AutoBillingService {
logger.info('AutoBilling', '自动记账成功',
'ids=${result.transactionIds}, 总金额=${result.totalAbsAmount}');
return result.firstTransactionId;
+ } on TimeoutException {
+ _logStage('ai_vision_timeout');
+ logger.warning('AutoBilling', 'AI 识别超时', '等待延迟响应: $imagePath');
+ if (showNotification) {
+ await _showFinalNotification(
+ progressId: notificationId,
+ finalId: resultNotificationId,
+ capturePath: imagePath,
+ title: '蜜蜂记账',
+ body: '⏳ 识别耗时较长,请稍等!',
+ );
+ }
+ _pendingTimeoutCount++;
+ _handleDelayedResponse(
+ imagePath: imagePath,
+ aiFuture: pendingAiFuture!,
+ finalId: resultNotificationId,
+ showNotification: showNotification,
+ );
+ return null;
} catch (e, stackTrace) {
+ _logStage('process_screenshot_error');
print('❌ 处理截图失败: $e');
logger.error('AutoBilling', '处理截图失败', {
'path': imagePath,
'error': e.toString(),
'stage': '未知阶段',
}, stackTrace);
+ if (showNotification) {
+ final l10n = _safeL10n();
+ try {
+ await _showFinalNotification(
+ progressId: notificationId,
+ finalId: resultNotificationId,
+ capturePath: imagePath,
+ title: l10n.autoBillingNotifyProcessFailedTitle,
+ body: l10n.autoBillingNotifyProcessFailedBody(e.toString()),
+ );
+ } catch (_) {
+ // 通知失败不影响流程
+ }
+ }
return null;
} finally {
final totalElapsed =
@@ -340,10 +418,13 @@ class AutoBillingService {
/// 核心:直接处理文本并自动记账(快捷指令推荐方式)
/// [text] 快捷指令传递的识别文本
/// [showNotification] 是否显示通知(默认true)
+ /// [showProgressNotifications] 是否显示中间进度通知(默认true)。
+ /// 摇一摇路径传 false,只保留最终结果通知横幅。
/// 返回:交易记录ID,失败返回null
Future processText(
String text, {
bool showNotification = true,
+ bool showProgressNotifications = true,
}) async {
final totalStartTime = DateTime.now().millisecondsSinceEpoch;
print('📝 [AutoBilling] 开始处理文本: $text');
@@ -351,7 +432,7 @@ class AutoBillingService {
try {
const notificationId = 1002;
const resultNotificationId = 1102;
- final l10n = lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ final l10n = _safeL10n();
// 兜底:AI text 未配置 → 系统通知,引导用户去配置
if (!await AIProviderManager.isCapabilityConfigured(
@@ -367,8 +448,8 @@ class AutoBillingService {
return null;
}
- // 显示"正在识别"通知
- if (showNotification) {
+ // 显示"正在识别"通知(摇一摇路径不显示进度通知)
+ if (showNotification && showProgressNotifications) {
await _showNotification(
id: notificationId,
title: l10n.autoBillingNotifyRecognizingTextTitle,
@@ -428,7 +509,7 @@ class AutoBillingService {
logger.error('AutoBilling', '文本处理失败', e);
if (showNotification) {
final l10n =
- lookupAppLocalizations(PlatformDispatcher.instance.locale);
+ _safeL10n();
await _showNotification(
id: 1002,
title: l10n.autoBillingNotifyProcessFailedTitle,
@@ -464,6 +545,85 @@ class AutoBillingService {
: l10n.autoBillingNotifySuccessSingleBodyDefault;
}
+ /// 取消摇一摇进度横幅(Android native 已发,Flutter 负责在结果到来时移除)
+ Future cancelShakeProgressBanner() async {
+ await _notificationsPlugin.cancel(9101);
+ }
+
+ /// 处理 AI 超时后的延迟响应。
+ ///
+ /// [aiFuture] 是 [fromImage] 的原生 Future,即便外层 .timeout() 已触发,
+ /// 它仍在后台运行。等 AI 最终响应后发送结果通知。
+ ///
+ /// 如果有多个待处理的延迟响应,通知正文末尾追加「(剩余 N 张待识别)」。
+ void _handleDelayedResponse({
+ required String imagePath,
+ required Future aiFuture,
+ required int finalId,
+ required bool showNotification,
+ }) {
+ // 安全网:60 秒后如果 AI 仍无响应(Dio 20s 超时意外失效的极端情况),
+ // 强制完成并通知用户,避免「⏳ 识别耗时较长」永久挂起
+ aiFuture
+ .timeout(const Duration(seconds: 60))
+ .then((result) async {
+ _pendingTimeoutCount--;
+ await _markAsProcessed(imagePath);
+
+ if (showNotification) {
+ final l10n = _safeL10n();
+ final suffix = _pendingTimeoutCount > 0
+ ? '(剩余 $_pendingTimeoutCount 张待识别)'
+ : '';
+
+ if (result.success) {
+ // 刷新统计 & 触发同步(fromImage 已落库,PostProcessor 需手动跑)
+ try {
+ final ledgerId = await _resolveLedgerId();
+ if (ledgerId != null) {
+ // ignore: invalid_use_of_visible_for_testing_member
+ _container.read(statsRefreshProvider.notifier).state++;
+ await PostProcessor.runC(_container,
+ ledgerId: ledgerId, tags: true);
+ }
+ } catch (_) {}
+ }
+
+ await _showFinalNotification(
+ progressId: finalId,
+ finalId: finalId,
+ capturePath: imagePath,
+ title: result.success
+ ? _successTitle(result, l10n)
+ : l10n.autoBillingNotifyRecognizeFailedTitle,
+ body: (result.success
+ ? _successBody(result, l10n)
+ : l10n.autoBillingNotifyRecognizeFailedBody) +
+ suffix,
+ );
+ }
+
+ if (result.success) {
+ logger.info('AutoBilling', '延迟响应:自动记账成功',
+ 'ids=${result.transactionIds}');
+ }
+ }).catchError((e, st) {
+ _pendingTimeoutCount--;
+ logger.error('AutoBilling', '延迟响应处理失败', '$e', st);
+ // 连兜底也失败了 → 不能再让「⏳ 识别耗时较长」挂在那
+ if (showNotification) {
+ final l10n = _safeL10n();
+ _showFinalNotification(
+ progressId: finalId,
+ finalId: finalId,
+ capturePath: imagePath,
+ title: l10n.autoBillingNotifyProcessFailedTitle,
+ body: l10n.autoBillingNotifyProcessFailedBody('识别超时,请稍后重试'),
+ );
+ }
+ });
+ }
+
/// 显示通知。
///
/// 通知失败**绝不向外抛**:通知只是进度提示,记账主流程不能因它中断。
@@ -481,6 +641,8 @@ class AutoBillingService {
channelDescription: '截图自动识别通知',
importance: Importance.high,
priority: Priority.high,
+ enableVibration: true,
+ playSound: true,
);
const iosDetails = DarwinNotificationDetails();
@@ -498,24 +660,96 @@ class AutoBillingService {
}
}
- /// 显示「最终结果」通知。
+ /// 显示「最终结果」通知(横幅弹出)。
///
- /// iOS 上,**用同一 ID 重复 `show()` 只会静默更新通知中心条目,不会重新弹
- /// banner**。所以「正在识别 → 成功/失败」如果共用 ID,用户只能看到第一条
- /// banner,直到进通知中心才看到结果。
- ///
- /// 这个方法用**新 ID** 发结果通知,iOS 把它当作新通知重新弹 banner。
- /// 不 cancel 旧的「正在识别」—— 实测在 AppIntent background-launch 状态下
- /// cancel + show 紧挨着的组合 iOS 会把它当成一次「替换」处理,banner 不弹;
- /// 留着旧的反而能保证新的作为独立通知正常弹出(旧的在结果通知出现后用户可自
- /// 行清理或自然过期)。
+ /// [capturePath] 为截屏文件路径。传入后优先通过原生 MethodChannel 发送,
+ /// 同时取消原生侧对应截图的 15s 超时定时器。不传时走原有降级链路。
Future _showFinalNotification({
required int progressId,
required int finalId,
required String title,
required String body,
+ String? capturePath,
}) async {
- await _showNotification(id: finalId, title: title, body: body);
+ // 优先走 captureResult(取消原生超时 + 原生通知)
+ if (capturePath != null) {
+ try {
+ await _shakeBillingControlChannel.invokeMethod('captureResult', {
+ 'path': capturePath,
+ 'title': title,
+ 'body': body,
+ });
+ logger.info('AutoBilling', '原生结果通知已发送', 'title=$title');
+ return;
+ } catch (e) {
+ logger.debug('AutoBilling', '原生通知发送失败,降级到 Flutter 通知: $e');
+ }
+ }
+
+ // 降级:Flutter 本地通知
+ try {
+ const androidDetails = AndroidNotificationDetails(
+ 'shake_result',
+ '摇一摇记账结果',
+ channelDescription: '摇一摇自动记账最终结果通知',
+ importance: Importance.high,
+ priority: Priority.high,
+ enableVibration: true,
+ playSound: true,
+ );
+
+ const iosDetails = DarwinNotificationDetails();
+
+ const details = NotificationDetails(
+ android: androidDetails,
+ iOS: iosDetails,
+ );
+
+ await _notificationsPlugin.show(finalId, title, body, details);
+ return;
+ } catch (e) {
+ logger.warning('AutoBilling', 'Flutter 通知也失败,保存待发送通知', '$e');
+ }
+
+ // 兜底:Engine 被 ROM 挂起时两种方式都失败,
+ // 保存到 SharedPreferences,下次启动时恢复展示
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('pending_notification_title', title);
+ await prefs.setString('pending_notification_body', body);
+ logger.info('AutoBilling', '待发送通知已保存到 SharedPreferences');
+ } catch (e) {
+ logger.error('AutoBilling', '保存待发送通知失败', e);
+ }
+ }
+
+ /// 检查并显示待发送的通知(应用启动时调用)
+ Future flushPendingNotification() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final title = prefs.getString('pending_notification_title');
+ final body = prefs.getString('pending_notification_body');
+ if (title == null || body == null) return;
+
+ await prefs.remove('pending_notification_title');
+ await prefs.remove('pending_notification_body');
+ logger.info('AutoBilling', '恢复待发送通知: $title');
+
+ await _notificationsPlugin.show(
+ 9102, title, body, const NotificationDetails(
+ android: AndroidNotificationDetails(
+ 'shake_result',
+ '摇一摇记账结果',
+ channelDescription: '摇一摇自动记账最终结果通知',
+ importance: Importance.high,
+ priority: Priority.high,
+ ),
+ iOS: DarwinNotificationDetails(),
+ ),
+ );
+ } catch (e) {
+ logger.error('AutoBilling', '恢复待发送通知失败', e);
+ }
}
/// 释放资源(AI 服务无 native handle,不需要 dispose,保留方法以备后续添加)
diff --git a/lib/services/platform/shake_billing_service.dart b/lib/services/platform/shake_billing_service.dart
new file mode 100644
index 000000000..c02147090
--- /dev/null
+++ b/lib/services/platform/shake_billing_service.dart
@@ -0,0 +1,360 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../automation/auto_billing_service.dart';
+
+/// 摇一摇自动记账服务(仅Android)
+///
+/// 管理无障碍截屏的接收、队列处理、保活服务联动,以及与系统无障碍设置的交互。
+/// 与 [ScreenshotMonitorService] 职责分离 — 截图监听走 ContentObserver,
+/// 摇一摇走 AccessibilityService,互不干扰。
+class ShakeBillingService {
+ static const _accessibilityChannel = MethodChannel('com.tntlikely.beecount/accessibility_billing');
+ static const _keepAliveChannel = MethodChannel('com.tntlikely.beecount/keep_alive');
+ static const _shakeBillingControlChannel = MethodChannel('com.tntlikely.beecount/shake_billing_control');
+ // 共享截图通道,用于无障碍设置查询(Android 端注册在 MainActivity)
+ static const _screenshotChannel = MethodChannel('com.tntlikely.beecount/screenshot');
+
+ static const _shakeBillingEnabledKey = 'shake_billing_enabled';
+ static const _keepAliveEnabledKey = 'keep_alive_enabled';
+
+ final ProviderContainer _container;
+ late final AutoBillingService _autoBillingService;
+
+ /// 保活服务是否已启用(内存状态,与摇一摇开关联动)
+ bool _isKeepAliveEnabled = false;
+
+ /// 摇一摇自动记账是否已启用(内存状态,避免后台异步事件中的竞态条件)
+ bool _isShakeBillingEnabled = false;
+
+ // ── 顺序处理队列(避免后台攒的多个截图同时触发 AI 造成通知轰炸) ──
+ final _captureQueue = [];
+ bool _isProcessingCapture = false;
+
+ // 单例模式
+ static ShakeBillingService? _instance;
+
+ factory ShakeBillingService(ProviderContainer container) {
+ _instance ??= ShakeBillingService._internal(container);
+ return _instance!;
+ }
+
+ ShakeBillingService._internal(this._container) {
+ _autoBillingService = AutoBillingService(_container);
+ _setupMethodCallHandler();
+ }
+
+ /// 设置方法调用处理器
+ void _setupMethodCallHandler() {
+ print('📳 [ShakeBilling] 初始化方法调用处理器');
+
+ // 摇一摇无障碍截屏监听通道
+ _accessibilityChannel.setMethodCallHandler((call) async {
+ print('📳 [ShakeBilling] 收到无障碍方法调用: ${call.method}');
+ if (call.method == 'onAccessibilityCapture') {
+ final data = call.arguments as String;
+ print('📳 [ShakeBilling] 无障碍截屏数据: $data');
+ await _handleAccessibilityCapture(data);
+ } else if (call.method == 'onAccessibilityServiceInterrupted') {
+ print('⚠️ [ShakeBilling] 无障碍服务被系统中断');
+ // Flutter 侧无需特殊处理,UI 会在 App resume 时刷新状态
+ }
+ });
+ }
+
+ // ── 摇一摇自动记账开关 ──
+
+ /// 摇一摇自动记账是否已启用
+ ///
+ /// 注意:摇一摇使用无障碍截图存私有目录,不依赖相册权限,
+ /// 因此不受 Google Play 构建限制,与截图监听独立判断。
+ Future isShakeBillingEnabled() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_shakeBillingEnabledKey) ?? false;
+ }
+
+ /// 启用摇一摇自动记账(同时自动开启保活服务)
+ Future enableShakeBilling() async {
+ if (!Platform.isAndroid) {
+ throw UnsupportedError('仅支持 Android 平台');
+ }
+
+ // 先设置内存状态(同步操作,无竞态风险)
+ _isShakeBillingEnabled = true;
+
+ try {
+ await _shakeBillingControlChannel.invokeMethod('enableShake');
+ } catch (e) {
+ print('⚠️ [ShakeBilling] 通知 Android 启用摇一摇失败: $e');
+ }
+
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_shakeBillingEnabledKey, true);
+ print('✅ [ShakeBilling] 摇一摇自动记账已启用');
+
+ // 同步启用保活服务(五层保活机制)
+ try {
+ await enableKeepAlive();
+ _isKeepAliveEnabled = true;
+ } catch (e) {
+ print('⚠️ [ShakeBilling] 启用保活服务失败(不影响摇一摇): $e');
+ }
+ }
+
+ /// 禁用摇一摇自动记账(同步关闭保活服务)
+ Future disableShakeBilling() async {
+ // 先设置内存状态(同步操作,阻止后续无障碍截屏入队)
+ _isShakeBillingEnabled = false;
+
+ try {
+ await _shakeBillingControlChannel.invokeMethod('disableShake');
+ } catch (e) {
+ print('⚠️ [ShakeBilling] 通知 Android 禁用摇一摇失败: $e');
+ }
+
+ // 清空待处理的截屏队列,取消正在进行的处理
+ _captureQueue.clear();
+ _isProcessingCapture = false;
+ print('🧹 [ShakeBilling] 已清空截屏处理队列');
+
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_shakeBillingEnabledKey, false);
+ print('✅ [ShakeBilling] 摇一摇自动记账已禁用');
+
+ try {
+ await disableKeepAlive();
+ _isKeepAliveEnabled = false;
+ } catch (e) {
+ print('⚠️ [ShakeBilling] 关闭保活服务失败: $e');
+ }
+ }
+
+ /// 处理无障碍截屏数据(摇一摇路径)
+ ///
+ /// [data] 有两种格式:
+ /// - 文件路径(API 31+ takeScreenshot) → 走 processScreenshot
+ /// - "text:" 前缀(API 30- 节点文本) → 走 processText
+ ///
+ /// 数据先入顺序队列,由 [_processCaptureQueue] 逐个处理,间隔 2 秒,
+ /// 防止 App 切后台期间攒的多个截图同时触发 AI 造成通知轰炸。
+ Future _handleAccessibilityCapture(String data) async {
+ // 内存状态检查 — Engine 重启后状态会丢失,回查持久化配置恢复
+ if (!_isShakeBillingEnabled) {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ _isShakeBillingEnabled = prefs.getBool(_shakeBillingEnabledKey) ?? false;
+ } catch (_) {}
+ if (!_isShakeBillingEnabled) {
+ print('⏭️ [ShakeBilling] 摇一摇已禁用,忽略无障碍截屏');
+ _logStage('shake_disabled_ignore');
+ return;
+ }
+ print('♻️ [ShakeBilling] 从持久化配置恢复摇一摇启用状态');
+ }
+
+ _captureQueue.add(data);
+ if (!_isProcessingCapture) {
+ _isProcessingCapture = true;
+ await _processCaptureQueue();
+ }
+ }
+
+ /// 记录 Dart 关键阶段到原生 Logcat(闪退后仍可通过日志查看)
+ void _logStage(String stage) {
+ print('🐛 [DartStage] $stage');
+ try {
+ _shakeBillingControlChannel.invokeMethod('logStage', {'stage': stage});
+ } catch (_) {}
+ }
+
+ /// 顺序消费 [_captureQueue],每次处理完一个等待 2 秒再处理下一个。
+ Future _processCaptureQueue() async {
+ _logStage('capture_queue_start');
+ while (_captureQueue.isNotEmpty) {
+ _logStage('capture_queue_process_item');
+ final data = _captureQueue.removeAt(0);
+ try {
+ if (data.startsWith('text:')) {
+ _logStage('process_text');
+ final text = data.substring(5);
+ if (text.trim().isEmpty) {
+ continue;
+ }
+ await _autoBillingService.processText(text, showProgressNotifications: false);
+ } else {
+ _logStage('process_screenshot');
+ await _autoBillingService.processScreenshot(data, showProgressNotifications: false);
+ // 清理私有缓存文件(无障碍截图存在 cacheDir,无存储权限限制)
+ try {
+ final file = File(data);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (_) {}
+ }
+ _logStage('cancel_progress_notification');
+ // AI 出结果了,关闭之前 Kotlin 发的进度通知
+ try {
+ await _keepAliveChannel.invokeMethod('cancelProgressNotification');
+ } catch (_) {}
+ _logStage('queue_delay');
+ // 每个结果间隔 2 秒,避免 AI 调用和通知同时弹出
+ await Future.delayed(const Duration(seconds: 2));
+ } catch (e) {
+ _logStage('capture_queue_error');
+ print('❌ [ShakeBilling] 处理截屏失败: $e');
+ // 即使出错也关闭进度通知
+ try {
+ await _keepAliveChannel.invokeMethod('cancelProgressNotification');
+ } catch (_) {}
+ }
+ }
+ _logStage('capture_queue_done');
+ _isProcessingCapture = false;
+ }
+
+ // ── 后台保活开关 ──
+
+ /// 保活是否已启用
+ Future isKeepAliveEnabled() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_keepAliveEnabledKey) ?? false;
+ }
+
+ /// 启用保活(启动前台保活服务 + 持久化状态)
+ Future enableKeepAlive() async {
+ if (!Platform.isAndroid) {
+ throw UnsupportedError('仅支持 Android 平台');
+ }
+ try {
+ await _keepAliveChannel.invokeMethod('startKeepAlive');
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_keepAliveEnabledKey, true);
+ print('✅ [ShakeBilling] 保活服务已启用');
+ } catch (e) {
+ print('❌ [ShakeBilling] 启用保活服务失败: $e');
+ rethrow;
+ }
+ }
+
+ /// 禁用保活(停止前台保活服务 + 持久化状态)
+ Future disableKeepAlive() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _keepAliveChannel.invokeMethod('stopKeepAlive');
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_keepAliveEnabledKey, false);
+ print('✅ [ShakeBilling] 保活服务已禁用');
+ } catch (e) {
+ print('❌ [ShakeBilling] 禁用保活服务失败: $e');
+ rethrow;
+ }
+ }
+
+ /// 保活前台服务是否在运行中
+ Future isKeepAliveRunning() async {
+ try {
+ final result = await _keepAliveChannel.invokeMethod('isKeepAliveRunning');
+ return result == true;
+ } catch (e) {
+ print('❌ [ShakeBilling] 检查保活服务状态失败: $e');
+ return false;
+ }
+ }
+
+ /// 是否已忽略电池优化
+ Future isIgnoringBatteryOptimizations() async {
+ try {
+ final result = await _keepAliveChannel.invokeMethod('isIgnoringBatteryOptimizations');
+ return result == true;
+ } catch (e) {
+ print('❌ [ShakeBilling] 检查电池优化状态失败: $e');
+ return false;
+ }
+ }
+
+ /// 打开系统电池优化设置页面
+ Future openBatteryOptimizationSettings() async {
+ try {
+ await _keepAliveChannel.invokeMethod('openBatteryOptimizationSettings');
+ } catch (e) {
+ print('❌ [ShakeBilling] 打开电池优化设置失败: $e');
+ }
+ }
+
+ /// 打开自启动管理页面(各 ROM 适配)
+ Future openAutoStartSettings() async {
+ try {
+ final result = await _keepAliveChannel.invokeMethod('openAutoStartSettings');
+ return result == true;
+ } catch (e) {
+ print('❌ [ShakeBilling] 打开自启动设置失败: $e');
+ return false;
+ }
+ }
+
+ /// 查询保活综合状态
+ Future